@goodandready/dsh-cron 0.2.15 → 0.2.17
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 +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +15 -0
- package/lib/api.js +3 -1
- package/lib/channels.js +3 -13
- package/lib/chat-start.js +6 -2
- package/lib/client.js +332 -215
- package/lib/failure-inspector.js +2 -8
- package/lib/index.js +18 -0
- package/lib/recipes.js +1 -1
- package/lib/runtimes.js +2 -2
- package/lib/scheduler.js +2 -2
- package/lib/silent-rule.js +4 -1
- package/lib/telegram.js +1 -1
- package/lib/updater.js +237 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -392,6 +392,20 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
|
|
|
392
392
|
|
|
393
393
|
|
|
394
394
|
|
|
395
|
+
### 29. Client Modular Decomposition & Theme Standardization (v0.2.17, #149, #150)
|
|
396
|
+
- **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", ...]`.
|
|
397
|
+
- **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.
|
|
398
|
+
- **Label Governance & Triage Audit (#95)**: Standardized 100% of repository issues and triage on the canonical repo-level label set.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
### 28. In-App One-Click Auto-Updater & Error Resilience (v0.2.16, #147, #155, #156)
|
|
403
|
+
- **Plugin Self-Updater (#147)**: Added in-app one-click self-updating module (`lib/updater.js`) with `/api/dsh-cron/update` endpoint and dedicated Settings UI card. Automatically queries npm registry, compares semver versions including pre-releases, and upgrades `@goodandready/dsh-cron` in-place through the DSH CLI without manual SSH sessions. POST updates are protected via origin validation (`rejectCrossOrigin`).
|
|
404
|
+
- **Silent Failure Elimination (#156)**: Replaced silent empty catches with diagnostic logging: uncompleted import transaction rollbacks are surfaced to system logs with warning severity, dynamic core module fallback reasons are logged, and session opening failures inform the user with actionable notifications.
|
|
405
|
+
- **Dead Code & Export Hygiene (#155)**: Cleaned unused legacy helpers (`CHANNEL_LABELS`, `makeInspectAsk`), stripped unnecessary exports from 16 internal modules, and wired `supportsSilentRule` directly into execution pipeline.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
395
409
|
## 📦 Installation
|
|
396
410
|
|
|
397
411
|
Install into your DeepSeek Harness web profile:
|
|
@@ -503,6 +517,7 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
|
|
|
503
517
|
| `POST` | `/dsh-cron/kanban/test` | Create a Kanban connectivity-test card |
|
|
504
518
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy alias for the task action routes (`run`, `toggle`, `delete`, `history`) |
|
|
505
519
|
| `GET` | `/dsh-cron/metrics` | Prometheus text exposition of task and run counters — never prompts or output (#53) |
|
|
520
|
+
| `GET` / `POST` | `/api/dsh-cron/update` | One-click plugin self-updater: query registry version and in-place upgrade (#147) |
|
|
506
521
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | External token-guarded surface: list / create-or-update (#54) |
|
|
507
522
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | External token-guarded surface: read / delete (#54) |
|
|
508
523
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | External token-guarded surface: force a run (#54) |
|
package/README.ru.md
CHANGED
|
@@ -268,6 +268,20 @@ cron_create_task({
|
|
|
268
268
|
|
|
269
269
|
|
|
270
270
|
|
|
271
|
+
### 29. Модульная декомпозиция клиента и стандартизация тем оформления (v0.2.17, #149, #150)
|
|
272
|
+
- **Модульная архитектура клиентской части (#150)**: Монолитный файл `lib/client.js` (~3950 строк) декомпозирован на 14 независимых специализированных модулей в каталоге `lib/client-src/` (каждый строго <= 580 строк). Сборка `lib/client.js` выполняется легковесным скриптом `scripts/build-client.mjs` перед запуском тестов и сборки. Исходные фрагменты исключены из npm-дистрибутива через `"files": ["lib/*.js", ...]`.
|
|
273
|
+
- **Семантические токены тем 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.
|
|
274
|
+
- **Аудит меток репозитория (#95)**: Подтверждена 100% консистентность использования канонического репозиторного набора меток и фильтрации задач.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
### 28. Модуль самообновления в 1 клик и устойчивость к ошибкам (v0.2.16, #147, #155, #156)
|
|
279
|
+
- **Самообновление плагина (#147)**: Добавлен встроенный модуль обновления (`lib/updater.js`) с маршрутом `/api/dsh-cron/update` и карточкой в настройках Web UI. Автоматически опрашивает npm registry, корректно сравнивает semver-версии (включая пререлизы) и обновляет `@goodandready/dsh-cron` на лету через CLI DSH без необходимости входа по SSH. Запросы POST защищены проверкой источника (`rejectCrossOrigin`).
|
|
280
|
+
- **Устранение скрытых сбоев (#156)**: Ликвидированы пустые `catch`: незавершённые откаты транзакций импорта логируются с уровнем `warn`, причины fallback для динамических модулей ядра фиксируются в отладочном журнале, а при сбое открытия сессии пользователь получает понятное уведомление.
|
|
281
|
+
- **Гигиена кода и экспортов (#155)**: Удалены устаревшие неиспользуемые сущности (`CHANNEL_LABELS`, `makeInspectAsk`), снят лишний модификатор `export` с 16 внутренних функций и констант, а метод `supportsSilentRule` напрямую подключен в боевой пайплайн проверки тихих правил.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
271
285
|
## 📦 Установка
|
|
272
286
|
|
|
273
287
|
```bash
|
|
@@ -500,6 +514,7 @@ bash deploy.sh verify [exact-version]
|
|
|
500
514
|
| `POST` | `/dsh-cron/kanban/test` | Тестовая карточка в Kanban |
|
|
501
515
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy-алиас действий над задачей (`run`, `toggle`, `delete`, `history`) |
|
|
502
516
|
| `GET` | `/dsh-cron/metrics` | Текст в формате Prometheus: счётчики задач и запусков — без промптов и вывода (#53) |
|
|
517
|
+
| `GET` / `POST` | `/api/dsh-cron/update` | Самообновление плагина в 1 клик: запрос версии в реестре и обновление на лету (#147) |
|
|
503
518
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | Внешняя поверхность под токеном: список / создание-обновление (#54) |
|
|
504
519
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | Внешняя поверхность под токеном: чтение / удаление (#54) |
|
|
505
520
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | Внешняя поверхность под токеном: принудительный запуск (#54) |
|
package/README.zh.md
CHANGED
|
@@ -391,6 +391,20 @@ bash deploy.sh verify [exact-version]
|
|
|
391
391
|
|
|
392
392
|
|
|
393
393
|
|
|
394
|
+
### 29. 客户端模块化解耦与原生主题标准化 (v0.2.17, #149, #150)
|
|
395
|
+
- **客户端模块化架构 (#150)**: 将庞大的单文件 `lib/client.js` (约 3950 行) 拆分为 `lib/client-src/` 下的 14 个高内聚模块文件 (各模块严格 <= 580 行)。集成零依赖构建脚本 `scripts/build-client.mjs` 并接入 `package.json` (`build:client`, `pretest`),通过 `"files": ["lib/*.js", ...]` 避免开发源码冗余打包进 npm 发布包。
|
|
396
|
+
- **DSH 语义化主题变量对齐 (#149)**: 将任务类型标签 (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) 的内联样式全部替换为基于主题变量的 `.dsh-cron-tag-*` 类;模态框遮罩层接入自适应主题遮罩变量 `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`,移除脉冲动画关键帧中的硬编码 RGBA。
|
|
397
|
+
- **标签治理与工单审计 (#95)**: 审计并确认全仓库 100% 统一规范使用仓库级标签集。
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
### 28. 应用内一键自更新与错误容错增强 (v0.2.16, #147, #155, #156)
|
|
402
|
+
- **插件自更新模块 (#147)**:新增应用内一键自更新机制 (`lib/updater.js`)、`/api/dsh-cron/update` 接口与设置面板专属卡片。自动轮询 npmjs 仓库最新版本,精准比对 Semver 版本号(含预发布版本),通过 DSH CLI 就地升级 `@goodandready/dsh-cron`,无需 SSH 终端操作。POST 请求受跨域与来源保护 (`rejectCrossOrigin`)。
|
|
403
|
+
- **消除静默异常与错误追踪 (#156)**:全面消除空 `catch` 异常压制:任务导入事务回滚失败记录 `warn` 级别日志、动态核心模块降级原因记录诊断日志、会话自动唤出失败向用户呈现友好提示。
|
|
404
|
+
- **死代码清理与模块导出精简 (#155)**:清理未引用的旧版实体 (`CHANNEL_LABELS`, `makeInspectAsk`),收回 16 个内部工具函数的暴露权限,并将 `supportsSilentRule` 接入静默规则核心执行流水线。
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
394
408
|
## 📦 安装
|
|
395
409
|
|
|
396
410
|
```bash
|
|
@@ -500,6 +514,7 @@ dsh-cron:
|
|
|
500
514
|
| `POST` | `/dsh-cron/kanban/test` | 创建 Kanban 连通性测试卡片 |
|
|
501
515
|
| `*` | `/dsh-cron/action/:id/:action` | 任务操作路由的兼容别名(`run`、`toggle`、`delete`、`history`) |
|
|
502
516
|
| `GET` | `/dsh-cron/metrics` | Prometheus 文本格式的任务与运行计数 —— 不含提示词与输出(#53) |
|
|
517
|
+
| `GET` / `POST` | `/api/dsh-cron/update` | 插件一键自更新:查询仓库最新版本并就地平滑升级 (#147) |
|
|
503
518
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | 令牌保护的外部接口:列表 / 创建或更新(#54) |
|
|
504
519
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | 令牌保护的外部接口:读取 / 删除(#54) |
|
|
505
520
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | 令牌保护的外部接口:强制执行(#54) |
|
package/lib/api.js
CHANGED
|
@@ -343,7 +343,9 @@ function applyImportPlan({ store, scheduler, plan }) {
|
|
|
343
343
|
try {
|
|
344
344
|
if (entry.previous) store.set(entry.previous);
|
|
345
345
|
else store.delete(entry.id);
|
|
346
|
-
} catch (
|
|
346
|
+
} catch (rollbackErr) {
|
|
347
|
+
console.warn(`[dsh-cron] import rollback failed for task "${entry.id}":`, rollbackErr?.message || rollbackErr);
|
|
348
|
+
}
|
|
347
349
|
}
|
|
348
350
|
return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
|
|
349
351
|
}
|
package/lib/channels.js
CHANGED
|
@@ -13,22 +13,12 @@ import { resolveTemplateText, truncateText } from './templates.js';
|
|
|
13
13
|
|
|
14
14
|
export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
telegram: 'Telegram',
|
|
18
|
-
kanban: 'dsh-kanban card',
|
|
19
|
-
discord: 'Discord webhook',
|
|
20
|
-
slack: 'Slack webhook',
|
|
21
|
-
ntfy: 'ntfy push',
|
|
22
|
-
bark: 'Bark push',
|
|
23
|
-
pushplus: 'PushPlus',
|
|
24
|
-
tts: 'Voice via dsh-tts',
|
|
25
|
-
gitea: 'Gitea issue',
|
|
26
|
-
};
|
|
16
|
+
// CHANNEL_LABELS removed (#155) — localized via lib/client.js
|
|
27
17
|
|
|
28
18
|
const isFailed = (status) => status === 'error' || status === 'timeout';
|
|
29
19
|
|
|
30
20
|
/** The message text for a channel: custom template, else built-in defaults. */
|
|
31
|
-
|
|
21
|
+
function messageTextFor(channelId, task, runInfo, settings = {}) {
|
|
32
22
|
const templates = (settings && settings.channelTemplates) || {};
|
|
33
23
|
const template = templates[channelId] || settings.template || '';
|
|
34
24
|
return resolveTemplateText({ template, task, runInfo });
|
|
@@ -247,7 +237,7 @@ async function postJson(http, url, headers, body, channelId, signal, timeoutMs)
|
|
|
247
237
|
* One handler per channel. Each returns a small detail object and throws a
|
|
248
238
|
* descriptive Error on failure; the router turns that into a failure entry.
|
|
249
239
|
*/
|
|
250
|
-
|
|
240
|
+
const CHANNEL_HANDLERS = {
|
|
251
241
|
async telegram({ task, runInfo, settings, secrets, http, signal, timeoutMs }) {
|
|
252
242
|
const botToken = secrets.botToken || '';
|
|
253
243
|
const chatId = String(settings.chatId || '').trim();
|
package/lib/chat-start.js
CHANGED
|
@@ -24,7 +24,9 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
|
|
|
24
24
|
if (llmModule && typeof llmModule.createUserMessage === 'function') {
|
|
25
25
|
createUserMessage = llmModule.createUserMessage;
|
|
26
26
|
}
|
|
27
|
-
} catch {
|
|
27
|
+
} catch (err) {
|
|
28
|
+
(ctx.logger || console).debug?.('[dsh-cron] @deepseek-ai/dsh-llm createUserMessage unavailable, using identity fallback:', err?.message || err);
|
|
29
|
+
}
|
|
28
30
|
|
|
29
31
|
let mintSessionId = () => `cron-setup-${randomUUID()}`;
|
|
30
32
|
try {
|
|
@@ -33,7 +35,9 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
|
|
|
33
35
|
const sid = mintSessionId();
|
|
34
36
|
mintSessionId = () => sessionModule.SessionId(sid);
|
|
35
37
|
}
|
|
36
|
-
} catch {
|
|
38
|
+
} catch (err) {
|
|
39
|
+
(ctx.logger || console).debug?.('[dsh-cron] @deepseek-ai/dsh-session SessionId unavailable, using string fallback:', err?.message || err);
|
|
40
|
+
}
|
|
37
41
|
|
|
38
42
|
const defaultSel = ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
39
43
|
const provider = body.provider || defaultSel?.provider;
|
package/lib/client.js
CHANGED
|
@@ -44,6 +44,17 @@ window.__ModuleLoader__.load({
|
|
|
44
44
|
// Locale dictionary: English is the canonical source language (Issue #87).
|
|
45
45
|
// Russian and other languages are provided at runtime by the translation
|
|
46
46
|
// plugin; no per-language duplicates are stored here.
|
|
47
|
+
// Core chevron primitive when the build ships it; own SVG fallback otherwise.
|
|
48
|
+
let CoreChevron = null;
|
|
49
|
+
try {
|
|
50
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
51
|
+
CoreChevron = primitives && primitives.IconChevronDownOutline14;
|
|
52
|
+
} catch (e) {}
|
|
53
|
+
const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
|
|
54
|
+
|
|
55
|
+
// Own React error boundary (#106): the core's SlotErrorBoundary renders an
|
|
56
|
+
// empty div on crash, so a slot failure would silently vanish. Ours keeps
|
|
57
|
+
// the failure visible with a retry.
|
|
47
58
|
const STRINGS = {
|
|
48
59
|
en: {
|
|
49
60
|
'sidebar.label': 'Scheduled tasks',
|
|
@@ -588,214 +599,6 @@ window.__ModuleLoader__.load({
|
|
|
588
599
|
};
|
|
589
600
|
const T = (key, vars) => translate(key, vars);
|
|
590
601
|
|
|
591
|
-
// Core chevron primitive when the build ships it; own SVG fallback otherwise.
|
|
592
|
-
let CoreChevron = null;
|
|
593
|
-
try {
|
|
594
|
-
const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
595
|
-
CoreChevron = primitives && primitives.IconChevronDownOutline14;
|
|
596
|
-
} catch (e) {}
|
|
597
|
-
const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
|
|
598
|
-
|
|
599
|
-
// Own React error boundary (#106): the core's SlotErrorBoundary renders an
|
|
600
|
-
// empty div on crash, so a slot failure would silently vanish. Ours keeps
|
|
601
|
-
// the failure visible with a retry.
|
|
602
|
-
function createErrorBoundary() {
|
|
603
|
-
if (!React || typeof React.Component !== 'function') {
|
|
604
|
-
return function NoopBoundary(props) { return (props && props.children) || null; };
|
|
605
|
-
}
|
|
606
|
-
return class ErrorBoundary extends React.Component {
|
|
607
|
-
constructor(props) {
|
|
608
|
-
super(props);
|
|
609
|
-
this.state = { hasError: false, error: null };
|
|
610
|
-
}
|
|
611
|
-
static getDerivedStateFromError(error) {
|
|
612
|
-
return { hasError: true, error };
|
|
613
|
-
}
|
|
614
|
-
componentDidCatch(error, errorInfo) {
|
|
615
|
-
console.error('[dsh-cron] React error:', error, errorInfo);
|
|
616
|
-
}
|
|
617
|
-
render() {
|
|
618
|
-
if (this.state.hasError) {
|
|
619
|
-
return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
|
|
620
|
-
React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
|
|
621
|
-
React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
|
|
622
|
-
React.createElement('button', {
|
|
623
|
-
type: 'button',
|
|
624
|
-
className: 'dsh-cron-btn-secondary',
|
|
625
|
-
style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
|
|
626
|
-
onClick: () => this.setState({ hasError: false, error: null })
|
|
627
|
-
}, 'Retry')
|
|
628
|
-
);
|
|
629
|
-
}
|
|
630
|
-
return (this.props && this.props.children) || null;
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
const ErrorBoundary = createErrorBoundary();
|
|
635
|
-
|
|
636
|
-
const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
|
|
637
|
-
const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
638
|
-
|
|
639
|
-
// Delivery channels (#26) — must stay in sync with lib/channels.js.
|
|
640
|
-
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
641
|
-
const CHANNEL_LABEL_KEYS = {
|
|
642
|
-
telegram: 'form.channelTelegram',
|
|
643
|
-
kanban: 'form.channelKanban',
|
|
644
|
-
discord: 'form.channelDiscord',
|
|
645
|
-
slack: 'form.channelSlack',
|
|
646
|
-
ntfy: 'form.channelNtfy',
|
|
647
|
-
bark: 'form.channelBark',
|
|
648
|
-
pushplus: 'form.channelPushplus',
|
|
649
|
-
tts: 'form.channelTts',
|
|
650
|
-
gitea: 'form.channelGitea',
|
|
651
|
-
};
|
|
652
|
-
|
|
653
|
-
// Non-secret delivery settings shown in the settings panel. Secrets are
|
|
654
|
-
// referenced by credential name only (#51) — never typed as values.
|
|
655
|
-
const DELIVERY_SETTING_FIELDS = [
|
|
656
|
-
{ key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
|
|
657
|
-
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
658
|
-
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
659
|
-
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
660
|
-
// The external API is the one surface where a typed secret is the point:
|
|
661
|
-
// a CI job cannot present a credential reference (#54).
|
|
662
|
-
{ key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
|
|
663
|
-
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
664
|
-
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
665
|
-
{ key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
|
|
666
|
-
{ key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
|
|
667
|
-
{ key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
|
|
668
|
-
{ key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
|
|
669
|
-
{ key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
|
|
670
|
-
{ key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
|
|
671
|
-
{ key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
|
|
672
|
-
{ key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
|
|
673
|
-
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
|
|
674
|
-
{ key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
|
|
675
|
-
{ key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
|
|
676
|
-
];
|
|
677
|
-
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
678
|
-
// The global template plus the per-channel override map (server model).
|
|
679
|
-
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
680
|
-
|
|
681
|
-
/** Collect every delivery key the settings endpoints may return. */
|
|
682
|
-
function pickDeliverySettings(source) {
|
|
683
|
-
const out = {};
|
|
684
|
-
for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
|
|
685
|
-
if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
|
|
686
|
-
}
|
|
687
|
-
if (out.channelTemplates && typeof out.channelTemplates === 'object') {
|
|
688
|
-
out.channelTemplates = Object.assign({}, out.channelTemplates);
|
|
689
|
-
}
|
|
690
|
-
return out;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/**
|
|
694
|
-
* Shared delivery form used by both the task panel modal and the plugin
|
|
695
|
-
* settings card, so a new channel is added in exactly one place.
|
|
696
|
-
* Sections collapse via the canonical aria-expanded head contract.
|
|
697
|
-
*/
|
|
698
|
-
function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
|
|
699
|
-
const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
|
|
700
|
-
const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
|
|
701
|
-
React.createElement('label', null, T(f.labelKey)),
|
|
702
|
-
React.createElement('input', f.numeric ? {
|
|
703
|
-
type: 'number',
|
|
704
|
-
min: f.min !== undefined ? f.min : 1000,
|
|
705
|
-
step: 1000,
|
|
706
|
-
value: val(f.key),
|
|
707
|
-
placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
|
|
708
|
-
// #115: clearing the field must send the explicit default. Deleting the
|
|
709
|
-
// key would leave the previously stored value in place (the settings
|
|
710
|
-
// scope skips absent keys), so the field would silently keep 1 ms.
|
|
711
|
-
onChange: (e) => onChange(f.key, e.target.value === ''
|
|
712
|
-
? (f.defaultValue !== undefined ? f.defaultValue : 15000)
|
|
713
|
-
: Number(e.target.value)),
|
|
714
|
-
} : {
|
|
715
|
-
type: 'text',
|
|
716
|
-
value: val(f.key),
|
|
717
|
-
placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
|
|
718
|
-
onChange: (e) => onChange(f.key, e.target.value)
|
|
719
|
-
})
|
|
720
|
-
);
|
|
721
|
-
const section = (key, titleKey, children) => {
|
|
722
|
-
const open = Boolean(sections[key]);
|
|
723
|
-
return React.createElement('div', { className: 'dsh-cron-section', key },
|
|
724
|
-
React.createElement('button', {
|
|
725
|
-
type: 'button',
|
|
726
|
-
className: 'dsh-cron-section-head',
|
|
727
|
-
'aria-expanded': open ? 'true' : 'false',
|
|
728
|
-
onClick: () => onToggleSection(key)
|
|
729
|
-
},
|
|
730
|
-
React.createElement('span', null, T(titleKey)),
|
|
731
|
-
React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
|
|
732
|
-
),
|
|
733
|
-
open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
|
|
734
|
-
);
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
return [
|
|
738
|
-
section('secrets', 'settings.secretsSection', [
|
|
739
|
-
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
|
|
740
|
-
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
|
|
741
|
-
]),
|
|
742
|
-
section('channels', 'settings.deliverySection', [
|
|
743
|
-
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
744
|
-
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
|
|
745
|
-
),
|
|
746
|
-
]),
|
|
747
|
-
section('automation', 'settings.automationSection', [
|
|
748
|
-
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
|
|
749
|
-
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
750
|
-
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
|
|
751
|
-
),
|
|
752
|
-
]),
|
|
753
|
-
section('templates', 'settings.templatesSection', [
|
|
754
|
-
React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
|
|
755
|
-
React.createElement('label', null, T('settings.templateLabel')),
|
|
756
|
-
React.createElement('input', {
|
|
757
|
-
type: 'text',
|
|
758
|
-
value: val('template'),
|
|
759
|
-
onChange: (e) => onChange('template', e.target.value)
|
|
760
|
-
}),
|
|
761
|
-
React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
|
|
762
|
-
),
|
|
763
|
-
React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
|
|
764
|
-
...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
|
|
765
|
-
React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
|
|
766
|
-
React.createElement('input', {
|
|
767
|
-
type: 'text',
|
|
768
|
-
value: (values.channelTemplates && values.channelTemplates[id]) || '',
|
|
769
|
-
onChange: (e) => onChannelTemplate(id, e.target.value)
|
|
770
|
-
})
|
|
771
|
-
))
|
|
772
|
-
),
|
|
773
|
-
]),
|
|
774
|
-
];
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/** Parse a KEY=VALUE textarea into the task env object (#38). */
|
|
778
|
-
function parseEnvText(text) {
|
|
779
|
-
const env = {};
|
|
780
|
-
for (const rawLine of String(text || '').split('\n')) {
|
|
781
|
-
const line = rawLine.trim();
|
|
782
|
-
if (!line || line.startsWith('#')) continue;
|
|
783
|
-
const idx = line.indexOf('=');
|
|
784
|
-
if (idx <= 0) continue;
|
|
785
|
-
const key = line.slice(0, idx).trim();
|
|
786
|
-
const value = line.slice(idx + 1).trim();
|
|
787
|
-
if (key) env[key] = value;
|
|
788
|
-
}
|
|
789
|
-
return Object.keys(env).length ? env : undefined;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
function formatEnvText(env) {
|
|
793
|
-
if (!env || typeof env !== 'object') return '';
|
|
794
|
-
return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// Status colors: core semantic state tokens first (same set dsh-clinebot
|
|
798
|
-
// uses), plugin fallbacks as a single point of change (#105).
|
|
799
602
|
const STYLES = `
|
|
800
603
|
:root {
|
|
801
604
|
--dsh-cron-success: var(--dsw-alias-state-success-primary, #10b981);
|
|
@@ -865,6 +668,12 @@ window.__ModuleLoader__.load({
|
|
|
865
668
|
.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; }
|
|
866
669
|
.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; }
|
|
867
670
|
.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; }
|
|
671
|
+
.dsh-cron-tag-success { background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); border-color: var(--dsh-cron-info); }
|
|
672
|
+
.dsh-cron-tag-failure { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
|
|
673
|
+
.dsh-cron-tag-heartbeat { background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); border-color: var(--dsh-cron-accent); }
|
|
674
|
+
.dsh-cron-tag-session { background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); border-color: var(--dsh-cron-info); }
|
|
675
|
+
.dsh-cron-tag-preflight { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
|
|
676
|
+
|
|
868
677
|
.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; }
|
|
869
678
|
.dsh-cron-task-actions { display: flex; gap: 8px; }
|
|
870
679
|
.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; }
|
|
@@ -883,7 +692,7 @@ window.__ModuleLoader__.load({
|
|
|
883
692
|
.dsh-cron-rec-time { font-size: 13px; color: var(--dsw-alias-label-secondary, #9ca3af); font-weight: normal; margin-left: 8px; }
|
|
884
693
|
.dsh-cron-rec-desc { font-size: 13px; color: var(--dsw-alias-label-tertiary, #71717a); }
|
|
885
694
|
|
|
886
|
-
.dsh-cron-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; box-sizing: border-box; overflow-y: auto; }
|
|
695
|
+
.dsh-cron-modal-overlay { position: fixed; inset: 0; background: var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75)); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; box-sizing: border-box; overflow-y: auto; }
|
|
887
696
|
.dsh-cron-modal { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 16px; width: 100%; max-width: 540px; max-height: min(90vh, calc(100vh - 36px)); overflow-y: auto; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); box-sizing: border-box; }
|
|
888
697
|
.dsh-cron-modal-scroll { max-width: 640px; max-height: min(90vh, calc(100vh - 36px)); overflow-y: auto; }
|
|
889
698
|
.dsh-cron-modal::-webkit-scrollbar { width: 6px; }
|
|
@@ -963,7 +772,7 @@ window.__ModuleLoader__.load({
|
|
|
963
772
|
.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); }
|
|
964
773
|
.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); }
|
|
965
774
|
.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); }
|
|
966
|
-
@keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0
|
|
775
|
+
@keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0 var(--dsh-cron-success-bg); } 70% { box-shadow: 0 0 0 9px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
|
|
967
776
|
.dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
968
777
|
|
|
969
778
|
/* #34: sidebar job list */
|
|
@@ -1026,6 +835,107 @@ window.__ModuleLoader__.load({
|
|
|
1026
835
|
el.textContent = STYLES;
|
|
1027
836
|
}
|
|
1028
837
|
|
|
838
|
+
function createErrorBoundary() {
|
|
839
|
+
if (!React || typeof React.Component !== 'function') {
|
|
840
|
+
return function NoopBoundary(props) { return (props && props.children) || null; };
|
|
841
|
+
}
|
|
842
|
+
return class ErrorBoundary extends React.Component {
|
|
843
|
+
constructor(props) {
|
|
844
|
+
super(props);
|
|
845
|
+
this.state = { hasError: false, error: null };
|
|
846
|
+
}
|
|
847
|
+
static getDerivedStateFromError(error) {
|
|
848
|
+
return { hasError: true, error };
|
|
849
|
+
}
|
|
850
|
+
componentDidCatch(error, errorInfo) {
|
|
851
|
+
console.error('[dsh-cron] React error:', error, errorInfo);
|
|
852
|
+
}
|
|
853
|
+
render() {
|
|
854
|
+
if (this.state.hasError) {
|
|
855
|
+
return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
|
|
856
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
|
|
857
|
+
React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
|
|
858
|
+
React.createElement('button', {
|
|
859
|
+
type: 'button',
|
|
860
|
+
className: 'dsh-cron-btn-secondary',
|
|
861
|
+
style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
|
|
862
|
+
onClick: () => this.setState({ hasError: false, error: null })
|
|
863
|
+
}, 'Retry')
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
return (this.props && this.props.children) || null;
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
const ErrorBoundary = createErrorBoundary();
|
|
871
|
+
|
|
872
|
+
const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
|
|
873
|
+
const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
874
|
+
|
|
875
|
+
// Delivery channels (#26) — must stay in sync with lib/channels.js.
|
|
876
|
+
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
877
|
+
const CHANNEL_LABEL_KEYS = {
|
|
878
|
+
telegram: 'form.channelTelegram',
|
|
879
|
+
kanban: 'form.channelKanban',
|
|
880
|
+
discord: 'form.channelDiscord',
|
|
881
|
+
slack: 'form.channelSlack',
|
|
882
|
+
ntfy: 'form.channelNtfy',
|
|
883
|
+
bark: 'form.channelBark',
|
|
884
|
+
pushplus: 'form.channelPushplus',
|
|
885
|
+
tts: 'form.channelTts',
|
|
886
|
+
gitea: 'form.channelGitea',
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// Non-secret delivery settings shown in the settings panel. Secrets are
|
|
890
|
+
// referenced by credential name only (#51) — never typed as values.
|
|
891
|
+
const DELIVERY_SETTING_FIELDS = [
|
|
892
|
+
{ key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
|
|
893
|
+
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
894
|
+
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
895
|
+
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
896
|
+
// The external API is the one surface where a typed secret is the point:
|
|
897
|
+
// a CI job cannot present a credential reference (#54).
|
|
898
|
+
{ key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
|
|
899
|
+
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
900
|
+
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
901
|
+
{ key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
|
|
902
|
+
{ key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
|
|
903
|
+
{ key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
|
|
904
|
+
{ key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
|
|
905
|
+
{ key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
|
|
906
|
+
{ key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
|
|
907
|
+
{ key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
|
|
908
|
+
{ key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
|
|
909
|
+
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
|
|
910
|
+
{ key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
|
|
911
|
+
{ key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
|
|
912
|
+
];
|
|
913
|
+
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
914
|
+
// The global template plus the per-channel override map (server model).
|
|
915
|
+
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
916
|
+
|
|
917
|
+
/** Collect every delivery key the settings endpoints may return. */
|
|
918
|
+
function parseEnvText(text) {
|
|
919
|
+
const env = {};
|
|
920
|
+
for (const rawLine of String(text || '').split('\n')) {
|
|
921
|
+
const line = rawLine.trim();
|
|
922
|
+
if (!line || line.startsWith('#')) continue;
|
|
923
|
+
const idx = line.indexOf('=');
|
|
924
|
+
if (idx <= 0) continue;
|
|
925
|
+
const key = line.slice(0, idx).trim();
|
|
926
|
+
const value = line.slice(idx + 1).trim();
|
|
927
|
+
if (key) env[key] = value;
|
|
928
|
+
}
|
|
929
|
+
return Object.keys(env).length ? env : undefined;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function formatEnvText(env) {
|
|
933
|
+
if (!env || typeof env !== 'object') return '';
|
|
934
|
+
return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Status colors: core semantic state tokens first (same set dsh-clinebot
|
|
938
|
+
// uses), plugin fallbacks as a single point of change (#105).
|
|
1029
939
|
function createToggle() {
|
|
1030
940
|
let open = false;
|
|
1031
941
|
const listeners = new Set();
|
|
@@ -1088,6 +998,7 @@ window.__ModuleLoader__.load({
|
|
|
1088
998
|
}
|
|
1089
999
|
}
|
|
1090
1000
|
}
|
|
1001
|
+
console.warn('[dsh-cron] could not open session in workspace:', sessionId);
|
|
1091
1002
|
return false;
|
|
1092
1003
|
}
|
|
1093
1004
|
|
|
@@ -1112,6 +1023,102 @@ window.__ModuleLoader__.load({
|
|
|
1112
1023
|
return (typeof t === 'function' ? t : translate)(key, vars);
|
|
1113
1024
|
}
|
|
1114
1025
|
|
|
1026
|
+
function pickDeliverySettings(source) {
|
|
1027
|
+
const out = {};
|
|
1028
|
+
for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
|
|
1029
|
+
if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
|
|
1030
|
+
}
|
|
1031
|
+
if (out.channelTemplates && typeof out.channelTemplates === 'object') {
|
|
1032
|
+
out.channelTemplates = Object.assign({}, out.channelTemplates);
|
|
1033
|
+
}
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Shared delivery form used by both the task panel modal and the plugin
|
|
1039
|
+
* settings card, so a new channel is added in exactly one place.
|
|
1040
|
+
* Sections collapse via the canonical aria-expanded head contract.
|
|
1041
|
+
*/
|
|
1042
|
+
function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
|
|
1043
|
+
const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
|
|
1044
|
+
const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
|
|
1045
|
+
React.createElement('label', null, T(f.labelKey)),
|
|
1046
|
+
React.createElement('input', f.numeric ? {
|
|
1047
|
+
type: 'number',
|
|
1048
|
+
min: f.min !== undefined ? f.min : 1000,
|
|
1049
|
+
step: 1000,
|
|
1050
|
+
value: val(f.key),
|
|
1051
|
+
placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
|
|
1052
|
+
// #115: clearing the field must send the explicit default. Deleting the
|
|
1053
|
+
// key would leave the previously stored value in place (the settings
|
|
1054
|
+
// scope skips absent keys), so the field would silently keep 1 ms.
|
|
1055
|
+
onChange: (e) => onChange(f.key, e.target.value === ''
|
|
1056
|
+
? (f.defaultValue !== undefined ? f.defaultValue : 15000)
|
|
1057
|
+
: Number(e.target.value)),
|
|
1058
|
+
} : {
|
|
1059
|
+
type: 'text',
|
|
1060
|
+
value: val(f.key),
|
|
1061
|
+
placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
|
|
1062
|
+
onChange: (e) => onChange(f.key, e.target.value)
|
|
1063
|
+
})
|
|
1064
|
+
);
|
|
1065
|
+
const section = (key, titleKey, children) => {
|
|
1066
|
+
const open = Boolean(sections[key]);
|
|
1067
|
+
return React.createElement('div', { className: 'dsh-cron-section', key },
|
|
1068
|
+
React.createElement('button', {
|
|
1069
|
+
type: 'button',
|
|
1070
|
+
className: 'dsh-cron-section-head',
|
|
1071
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
1072
|
+
onClick: () => onToggleSection(key)
|
|
1073
|
+
},
|
|
1074
|
+
React.createElement('span', null, T(titleKey)),
|
|
1075
|
+
React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
|
|
1076
|
+
),
|
|
1077
|
+
open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
|
|
1078
|
+
);
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
return [
|
|
1082
|
+
section('secrets', 'settings.secretsSection', [
|
|
1083
|
+
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
|
|
1084
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
|
|
1085
|
+
]),
|
|
1086
|
+
section('channels', 'settings.deliverySection', [
|
|
1087
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
1088
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
|
|
1089
|
+
),
|
|
1090
|
+
]),
|
|
1091
|
+
section('automation', 'settings.automationSection', [
|
|
1092
|
+
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
|
|
1093
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
1094
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
|
|
1095
|
+
),
|
|
1096
|
+
]),
|
|
1097
|
+
section('templates', 'settings.templatesSection', [
|
|
1098
|
+
React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
|
|
1099
|
+
React.createElement('label', null, T('settings.templateLabel')),
|
|
1100
|
+
React.createElement('input', {
|
|
1101
|
+
type: 'text',
|
|
1102
|
+
value: val('template'),
|
|
1103
|
+
onChange: (e) => onChange('template', e.target.value)
|
|
1104
|
+
}),
|
|
1105
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
|
|
1106
|
+
),
|
|
1107
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
|
|
1108
|
+
...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
|
|
1109
|
+
React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
|
|
1110
|
+
React.createElement('input', {
|
|
1111
|
+
type: 'text',
|
|
1112
|
+
value: (values.channelTemplates && values.channelTemplates[id]) || '',
|
|
1113
|
+
onChange: (e) => onChannelTemplate(id, e.target.value)
|
|
1114
|
+
})
|
|
1115
|
+
))
|
|
1116
|
+
),
|
|
1117
|
+
]),
|
|
1118
|
+
];
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** Parse a KEY=VALUE textarea into the task env object (#38). */
|
|
1115
1122
|
function CronScreen(props) {
|
|
1116
1123
|
const { ctx, toggle, onClose } = props;
|
|
1117
1124
|
const t = props.t || translate;
|
|
@@ -2216,11 +2223,11 @@ window.__ModuleLoader__.load({
|
|
|
2216
2223
|
title: T_KEY(t, 'list.configManagedHint')
|
|
2217
2224
|
}, T_KEY(t, 'list.configManaged')) : null,
|
|
2218
2225
|
task.lastDurationMs > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag', title: 'Last execution duration' }, task.lastDurationMs + 'ms') : null,
|
|
2219
|
-
task.onSuccess ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'On success: triggers ' + task.onSuccess
|
|
2220
|
-
task.onFailure ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'On failure: triggers ' + task.onFailure
|
|
2221
|
-
task.heartbeatIntervalSeconds > 0 ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'Heartbeat: every ' + task.heartbeatIntervalSeconds + 's'
|
|
2222
|
-
task.targetSessionId ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'Target session: ' + task.targetSessionId + (task.targetSessionReset && task.targetSessionReset !== 'never' ? ' (' + task.targetSessionReset + ')' : '')
|
|
2223
|
-
task.preflightType && task.preflightType !== 'none' ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'Preflight: ' + task.preflightType
|
|
2226
|
+
task.onSuccess ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-success', title: 'On success: triggers ' + task.onSuccess }, '➜ ' + task.onSuccess) : null,
|
|
2227
|
+
task.onFailure ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-failure', title: 'On failure: triggers ' + task.onFailure }, '↳ ' + task.onFailure) : null,
|
|
2228
|
+
task.heartbeatIntervalSeconds > 0 ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-heartbeat', title: 'Heartbeat: every ' + task.heartbeatIntervalSeconds + 's' }, '💓 ' + task.heartbeatIntervalSeconds + 's') : null,
|
|
2229
|
+
task.targetSessionId ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-session', title: 'Target session: ' + task.targetSessionId + (task.targetSessionReset && task.targetSessionReset !== 'never' ? ' (' + task.targetSessionReset + ')' : '') }, '🧵 ' + task.targetSessionId) : null,
|
|
2230
|
+
task.preflightType && task.preflightType !== 'none' ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-preflight', title: 'Preflight: ' + task.preflightType }, '🛡️ ' + task.preflightType) : null,
|
|
2224
2231
|
task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
|
|
2225
2232
|
task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null
|
|
2226
2233
|
),
|
|
@@ -3316,6 +3323,65 @@ window.__ModuleLoader__.load({
|
|
|
3316
3323
|
const [testInfo, setTestInfo] = React.useState(null);
|
|
3317
3324
|
// Collapsed by default, head is the toggle (canonical card contract, #100)
|
|
3318
3325
|
const [cardOpen, setCardOpen] = React.useState(false);
|
|
3326
|
+
const [updateState, setUpdateState] = React.useState({
|
|
3327
|
+
checking: false,
|
|
3328
|
+
updating: false,
|
|
3329
|
+
currentVersion: '0.2.17',
|
|
3330
|
+
latestVersion: undefined,
|
|
3331
|
+
updateAvailable: false,
|
|
3332
|
+
notice: null,
|
|
3333
|
+
error: null
|
|
3334
|
+
});
|
|
3335
|
+
|
|
3336
|
+
const checkUpdates = async () => {
|
|
3337
|
+
setUpdateState(prev => ({ ...prev, checking: true, error: null, notice: null }));
|
|
3338
|
+
try {
|
|
3339
|
+
const res = await fetch('/api/dsh-cron/update');
|
|
3340
|
+
const data = await res.json();
|
|
3341
|
+
if (data && data.currentVersion) {
|
|
3342
|
+
setUpdateState(prev => ({
|
|
3343
|
+
...prev,
|
|
3344
|
+
checking: false,
|
|
3345
|
+
currentVersion: data.currentVersion,
|
|
3346
|
+
latestVersion: data.latestVersion,
|
|
3347
|
+
updateAvailable: Boolean(data.updateAvailable),
|
|
3348
|
+
error: data.latestCheckFailed ? 'Registry check failed' : null
|
|
3349
|
+
}));
|
|
3350
|
+
} else {
|
|
3351
|
+
setUpdateState(prev => ({ ...prev, checking: false, error: data.error || 'Check failed' }));
|
|
3352
|
+
}
|
|
3353
|
+
} catch (err) {
|
|
3354
|
+
setUpdateState(prev => ({ ...prev, checking: false, error: err.message }));
|
|
3355
|
+
}
|
|
3356
|
+
};
|
|
3357
|
+
|
|
3358
|
+
const runUpdate = async () => {
|
|
3359
|
+
setUpdateState(prev => ({ ...prev, updating: true, error: null, notice: null }));
|
|
3360
|
+
try {
|
|
3361
|
+
const res = await fetch('/api/dsh-cron/update', {
|
|
3362
|
+
method: 'POST',
|
|
3363
|
+
headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' }
|
|
3364
|
+
});
|
|
3365
|
+
const data = await res.json();
|
|
3366
|
+
if (data && data.updated) {
|
|
3367
|
+
setUpdateState(prev => ({
|
|
3368
|
+
...prev,
|
|
3369
|
+
updating: false,
|
|
3370
|
+
updateAvailable: false,
|
|
3371
|
+
currentVersion: data.updatedVersion || data.latestVersion,
|
|
3372
|
+
notice: T_KEY(t, 'updater.success', { version: data.updatedVersion || data.latestVersion })
|
|
3373
|
+
}));
|
|
3374
|
+
} else {
|
|
3375
|
+
setUpdateState(prev => ({
|
|
3376
|
+
...prev,
|
|
3377
|
+
updating: false,
|
|
3378
|
+
error: data.error || data.message || 'Update failed'
|
|
3379
|
+
}));
|
|
3380
|
+
}
|
|
3381
|
+
} catch (err) {
|
|
3382
|
+
setUpdateState(prev => ({ ...prev, updating: false, error: err.message }));
|
|
3383
|
+
}
|
|
3384
|
+
};
|
|
3319
3385
|
|
|
3320
3386
|
// Settings snapshot status (#102): never render phantom inputs before
|
|
3321
3387
|
// the snapshot arrives; offer a retry when it is unavailable.
|
|
@@ -3521,6 +3587,57 @@ window.__ModuleLoader__.load({
|
|
|
3521
3587
|
'data-type': testInfo.success ? 'success' : 'error',
|
|
3522
3588
|
style: { margin: 0, padding: '8px 12px' }
|
|
3523
3589
|
}, testInfo.message),
|
|
3590
|
+
React.createElement('div', {
|
|
3591
|
+
className: 'dsh-cron-form-group',
|
|
3592
|
+
style: {
|
|
3593
|
+
background: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03))',
|
|
3594
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
|
|
3595
|
+
borderRadius: '8px',
|
|
3596
|
+
padding: '14px',
|
|
3597
|
+
marginBottom: '16px'
|
|
3598
|
+
}
|
|
3599
|
+
},
|
|
3600
|
+
React.createElement('div', {
|
|
3601
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }
|
|
3602
|
+
},
|
|
3603
|
+
React.createElement('div', { style: { fontWeight: '600', fontSize: '14px' } }, `🚀 ${T_KEY(t, 'updater.title')}`),
|
|
3604
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
3605
|
+
React.createElement('button', {
|
|
3606
|
+
type: 'button',
|
|
3607
|
+
className: 'dsh-cron-btn-secondary',
|
|
3608
|
+
style: { padding: '4px 10px', fontSize: '12px' },
|
|
3609
|
+
disabled: updateState.checking || updateState.updating,
|
|
3610
|
+
onClick: checkUpdates
|
|
3611
|
+
}, updateState.checking ? T_KEY(t, 'updater.checking') : T_KEY(t, 'updater.btnCheck')),
|
|
3612
|
+
updateState.updateAvailable ? React.createElement('button', {
|
|
3613
|
+
type: 'button',
|
|
3614
|
+
className: 'dsh-cron-btn-primary',
|
|
3615
|
+
style: { padding: '4px 10px', fontSize: '12px' },
|
|
3616
|
+
disabled: updateState.updating,
|
|
3617
|
+
onClick: runUpdate
|
|
3618
|
+
}, updateState.updating ? T_KEY(t, 'updater.updating') : T_KEY(t, 'updater.btnUpdate', { version: updateState.latestVersion })) : null
|
|
3619
|
+
)
|
|
3620
|
+
),
|
|
3621
|
+
React.createElement('div', {
|
|
3622
|
+
style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #999)', marginBottom: '8px' }
|
|
3623
|
+
}, T_KEY(t, 'updater.desc')),
|
|
3624
|
+
React.createElement('div', {
|
|
3625
|
+
style: { display: 'flex', alignItems: 'center', gap: '12px', fontSize: '13px' }
|
|
3626
|
+
},
|
|
3627
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #aaa)' } },
|
|
3628
|
+
T_KEY(t, 'updater.current', { version: updateState.currentVersion })
|
|
3629
|
+
),
|
|
3630
|
+
updateState.checking ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-info' }, T_KEY(t, 'updater.checking')) :
|
|
3631
|
+
updateState.updateAvailable ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-warn' }, T_KEY(t, 'updater.available', { version: updateState.latestVersion })) :
|
|
3632
|
+
updateState.currentVersion && !updateState.error ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-ok' }, `✓ ${T_KEY(t, 'updater.upToDate')}`) : null
|
|
3633
|
+
),
|
|
3634
|
+
updateState.notice ? React.createElement('div', {
|
|
3635
|
+
style: { marginTop: '8px', color: 'var(--dsw-alias-success, #10b981)', fontSize: '12px' }
|
|
3636
|
+
}, updateState.notice) : null,
|
|
3637
|
+
updateState.error ? React.createElement('div', {
|
|
3638
|
+
style: { marginTop: '8px', color: 'var(--dsw-alias-error, #ef4444)', fontSize: '12px' }
|
|
3639
|
+
}, updateState.error) : null
|
|
3640
|
+
),
|
|
3524
3641
|
React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '4px' } },
|
|
3525
3642
|
React.createElement('button', {
|
|
3526
3643
|
type: 'submit',
|
package/lib/failure-inspector.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
|
|
14
14
|
|
|
15
15
|
/** Agent-mediated tasks have a prompt worth revising. */
|
|
16
|
-
|
|
16
|
+
const INSPECTABLE_TASK_TYPES = ['llm', 'skill', 'workflow'];
|
|
17
17
|
|
|
18
18
|
export function supportsInspection(task) {
|
|
19
19
|
const type = String((task && task.type) || 'llm');
|
|
@@ -98,10 +98,4 @@ export async function inspectFailure({ task, runInfo, ask, preferredModel, timeo
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
export function makeInspectAsk(ctx) {
|
|
103
|
-
return async ({ prompt, system, preferredModel, maxTokens, timeoutMs, task }) => {
|
|
104
|
-
const target = resolveAskTarget(ctx, task || {}, preferredModel);
|
|
105
|
-
return askModel(ctx, { ...target, prompt, system, maxTokens, timeoutMs });
|
|
106
|
-
};
|
|
107
|
-
}
|
|
101
|
+
// makeInspectAsk removed (#155) — scheduler uses makeAsk(ctx) from lib/llm-ask.js
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { registerPluginUpdater } from './updater.js';
|
|
2
|
+
import { credentialRefStatus } from './secrets.js';
|
|
1
3
|
import { TaskStore } from './store.js';
|
|
2
4
|
import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
|
|
3
5
|
import { SessionRunner } from './runner.js';
|
|
@@ -165,6 +167,15 @@ export function sanitizeSettingsPayload(body) {
|
|
|
165
167
|
delete payload[key];
|
|
166
168
|
}
|
|
167
169
|
}
|
|
170
|
+
for (const key of ['botTokenRef', 'barkKeyRef', 'giteaTokenRef']) {
|
|
171
|
+
if (key in payload && typeof payload[key] === 'string' && payload[key].trim()) {
|
|
172
|
+
const refCheck = credentialRefStatus(payload[key]);
|
|
173
|
+
if (!refCheck.ok) {
|
|
174
|
+
console.warn(`[dsh-cron] invalid credential reference in "${key}": ${refCheck.error}`);
|
|
175
|
+
delete payload[key];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
168
179
|
for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
|
|
169
180
|
if (key in payload) {
|
|
170
181
|
console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
|
|
@@ -378,6 +389,13 @@ export function apply(ctx, config) {
|
|
|
378
389
|
// #48: the recipe hub is the single source of the panel's suggestions.
|
|
379
390
|
const recommendations = recipeRecommendations();
|
|
380
391
|
|
|
392
|
+
// Auto-updater route (#147)
|
|
393
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
394
|
+
endpoint: '/api/dsh-cron/update',
|
|
395
|
+
packageName: '@goodandready/dsh-cron',
|
|
396
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
397
|
+
}), 'dsh-cron: /api/dsh-cron/update');
|
|
398
|
+
|
|
381
399
|
// 1. REST API — collection (list/create) + item actions in one prefix
|
|
382
400
|
// handler per route family; registered WITHOUT the trailing slash (#96)
|
|
383
401
|
ctx.effect(() => ctx.webServer.register({
|
package/lib/recipes.js
CHANGED
|
@@ -34,7 +34,7 @@ export const RECIPE_CATEGORIES = [
|
|
|
34
34
|
* packages, users and permissions, scheduling and the network edge, power state
|
|
35
35
|
* and writes outside the working tree.
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
38
38
|
/\brm\b/i,
|
|
39
39
|
/\bunlink\b/i,
|
|
40
40
|
/\bshred\b/i,
|
package/lib/runtimes.js
CHANGED
|
@@ -10,7 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
|
|
12
12
|
export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
|
|
13
|
-
|
|
13
|
+
const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
|
|
14
14
|
export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
|
|
15
15
|
|
|
16
16
|
/** Types that execute code and therefore need the HTTP confirm header. */
|
|
@@ -56,7 +56,7 @@ export function buildNodeInvocation(task, cwd) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
|
|
59
|
-
|
|
59
|
+
function detectPython(task, cwd) {
|
|
60
60
|
if (task.pythonPath) return task.pythonPath;
|
|
61
61
|
const candidates = [];
|
|
62
62
|
if (process.env.VIRTUAL_ENV) {
|
package/lib/scheduler.js
CHANGED
|
@@ -6,10 +6,10 @@ import { applySilentRule } from './silent-rule.js';
|
|
|
6
6
|
import { inspectFailure } from './failure-inspector.js';
|
|
7
7
|
|
|
8
8
|
/** Task types that talk to a model and can therefore use a fallback model. */
|
|
9
|
-
|
|
9
|
+
const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
|
|
10
10
|
|
|
11
11
|
/** Sum two usage records, so a fallback attempt is not lost in accounting. */
|
|
12
|
-
|
|
12
|
+
function addUsage(a, b) {
|
|
13
13
|
return {
|
|
14
14
|
inputTokens: (a?.inputTokens || 0) + (b?.inputTokens || 0),
|
|
15
15
|
outputTokens: (a?.outputTokens || 0) + (b?.outputTokens || 0),
|
package/lib/silent-rule.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
|
|
16
16
|
|
|
17
17
|
/** Task types whose runs produce output a rule can judge. */
|
|
18
|
-
|
|
18
|
+
const SILENT_RULE_TASK_TYPES = ['script', 'node', 'python', 'http'];
|
|
19
19
|
|
|
20
20
|
export function supportsSilentRule(task) {
|
|
21
21
|
return SILENT_RULE_TASK_TYPES.includes(String(task && task.type ? task.type : 'script'));
|
|
@@ -64,6 +64,9 @@ export async function applySilentRule({ task, runInfo, ask, preferredModel, time
|
|
|
64
64
|
if (!task || !task.silentRule || String(task.silentRule).trim() === '') {
|
|
65
65
|
return { skipped: false, reason: '' };
|
|
66
66
|
}
|
|
67
|
+
if (!supportsSilentRule(task)) {
|
|
68
|
+
return { skipped: false, reason: '', note: 'task type does not support silent rule' };
|
|
69
|
+
}
|
|
67
70
|
if ((runInfo && runInfo.status) !== 'success') {
|
|
68
71
|
return { skipped: false, reason: '', note: 'the rule only judges successful runs' };
|
|
69
72
|
}
|
package/lib/telegram.js
CHANGED
|
@@ -79,7 +79,7 @@ export function getDshDefaultTelegramCredentials() {
|
|
|
79
79
|
/**
|
|
80
80
|
* Escape markdown special characters for Telegram legacy markdown.
|
|
81
81
|
*/
|
|
82
|
-
|
|
82
|
+
function escapeMarkdown(str) {
|
|
83
83
|
if (!str) return '';
|
|
84
84
|
return String(str).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
|
|
85
85
|
}
|
package/lib/updater.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { rejectCrossOrigin } from './http-utils.js';
|
|
8
|
+
|
|
9
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
const VERSION_CACHE_MS = 5 * 60_000;
|
|
11
|
+
let latestCache = null;
|
|
12
|
+
|
|
13
|
+
function validProfileName(value) {
|
|
14
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
|
|
15
|
+
&& !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function profileNameFromArgv(argv) {
|
|
19
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
20
|
+
if (argv[index] === '--profile' || argv[index] === '-p') return argv[index + 1];
|
|
21
|
+
if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
|
|
22
|
+
}
|
|
23
|
+
return argv[2] === 'web' ? 'web' : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findDshCliEntry() {
|
|
27
|
+
const value = process.argv[1];
|
|
28
|
+
if (value === undefined || value === '') return undefined;
|
|
29
|
+
const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
|
|
30
|
+
if (!existsSync(entry)) return undefined;
|
|
31
|
+
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
32
|
+
const manifestPath = resolve(directory, 'package.json');
|
|
33
|
+
if (existsSync(manifestPath)) {
|
|
34
|
+
try {
|
|
35
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
36
|
+
const bin = typeof manifest.bin === 'string'
|
|
37
|
+
? manifest.bin
|
|
38
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
39
|
+
? manifest.bin.dsh
|
|
40
|
+
: undefined;
|
|
41
|
+
if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
|
|
42
|
+
&& !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
|
|
43
|
+
} catch {
|
|
44
|
+
// Continue searching parent directories
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const parent = dirname(directory);
|
|
48
|
+
if (parent === directory) return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function runtime() {
|
|
53
|
+
const profileDir = resolve(process.env.DSH_PROFILE_DIR
|
|
54
|
+
?? resolve(homedir(), '.dsh', 'profiles', 'web'));
|
|
55
|
+
const selected = profileNameFromArgv(process.argv);
|
|
56
|
+
const profileName = validProfileName(selected)
|
|
57
|
+
? selected
|
|
58
|
+
: validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
|
|
59
|
+
const cliEntry = findDshCliEntry();
|
|
60
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseSemver(value) {
|
|
64
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim());
|
|
65
|
+
if (match === null) return undefined;
|
|
66
|
+
return {
|
|
67
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
68
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function comparePrerelease(left, right) {
|
|
73
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
74
|
+
const length = Math.max(left.length, right.length);
|
|
75
|
+
for (let index = 0; index < length; index += 1) {
|
|
76
|
+
const a = left[index];
|
|
77
|
+
const b = right[index];
|
|
78
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
|
|
79
|
+
if (a === b) continue;
|
|
80
|
+
const aNumeric = /^\d+$/.test(a);
|
|
81
|
+
const bNumeric = /^\d+$/.test(b);
|
|
82
|
+
if (aNumeric && bNumeric) {
|
|
83
|
+
const aNumber = BigInt(a);
|
|
84
|
+
const bNumber = BigInt(b);
|
|
85
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
89
|
+
return a > b ? 1 : -1;
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
95
|
+
const current = parseSemver(currentValue);
|
|
96
|
+
const candidate = parseSemver(candidateValue);
|
|
97
|
+
if (current === undefined || candidate === undefined) return false;
|
|
98
|
+
for (let index = 0; index < 3; index += 1) {
|
|
99
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
|
|
100
|
+
}
|
|
101
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function latestVersion(packageName, registry = 'https://registry.npmjs.org') {
|
|
105
|
+
if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
|
|
106
|
+
return latestCache.version;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const cleanReg = registry.replace(/\/$/, '');
|
|
110
|
+
const response = await fetch(`${cleanReg}/${encodeURIComponent(packageName)}/latest`, {
|
|
111
|
+
signal: AbortSignal.timeout(8000),
|
|
112
|
+
headers: { accept: 'application/json' },
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) return undefined;
|
|
115
|
+
const value = await response.json();
|
|
116
|
+
if (typeof value.version !== 'string' || value.version === '') return undefined;
|
|
117
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
|
|
118
|
+
return value.version;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function currentVersion(manifestUrl) {
|
|
125
|
+
const raw = await readFile(manifestUrl, 'utf8');
|
|
126
|
+
const value = JSON.parse(raw);
|
|
127
|
+
if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
|
|
128
|
+
return value.version;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function getUpdateStatus(options, target = runtime()) {
|
|
132
|
+
const current = await currentVersion(options.manifestUrl);
|
|
133
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
|
|
134
|
+
return {
|
|
135
|
+
packageName: options.packageName,
|
|
136
|
+
currentVersion: current,
|
|
137
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
138
|
+
latestCheckFailed: latest === undefined,
|
|
139
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
140
|
+
profileName: target.profileName,
|
|
141
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function installExact(target, packageSpec, options = {}) {
|
|
146
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
|
|
147
|
+
return new Promise((resolvePromise, reject) => {
|
|
148
|
+
// IMPORTANT: As required by #147, do NOT add minimumReleaseAge bypass
|
|
149
|
+
const args = [
|
|
150
|
+
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
151
|
+
packageSpec,
|
|
152
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
153
|
+
];
|
|
154
|
+
const child = spawn(process.execPath, args, {
|
|
155
|
+
cwd: target.profileDir,
|
|
156
|
+
windowsHide: true,
|
|
157
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
158
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
159
|
+
});
|
|
160
|
+
let detail = '';
|
|
161
|
+
child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
|
|
162
|
+
child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
child.kill();
|
|
165
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'));
|
|
166
|
+
}, UPDATE_TIMEOUT_MS);
|
|
167
|
+
child.once('error', error => { clearTimeout(timer); reject(error); });
|
|
168
|
+
child.once('exit', code => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
if (code === 0) resolvePromise();
|
|
171
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function registerPluginUpdater(ctx, options) {
|
|
177
|
+
let installing = false;
|
|
178
|
+
return ctx.webServer.register({
|
|
179
|
+
kind: 'exact',
|
|
180
|
+
path: options.endpoint || '/api/dsh-cron/update',
|
|
181
|
+
handler: async (req, res) => {
|
|
182
|
+
try {
|
|
183
|
+
const target = runtime();
|
|
184
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
185
|
+
const payload = await getUpdateStatus(options, target);
|
|
186
|
+
res.writeHead(200, {
|
|
187
|
+
'content-type': 'application/json; charset=utf-8',
|
|
188
|
+
'cache-control': 'no-store',
|
|
189
|
+
});
|
|
190
|
+
res.end(req.method === 'HEAD' ? undefined : JSON.stringify(payload));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (req.method !== 'POST') {
|
|
194
|
+
res.writeHead(405, { allow: 'GET, HEAD, POST' });
|
|
195
|
+
res.end();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (rejectCrossOrigin(req, res)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (installing) {
|
|
202
|
+
res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' });
|
|
203
|
+
res.end(JSON.stringify({ ok: false, error: 'This plugin is already updating.' }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
installing = true;
|
|
207
|
+
try {
|
|
208
|
+
const before = await getUpdateStatus(options, target);
|
|
209
|
+
if (before.latestVersion === undefined) {
|
|
210
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
|
|
211
|
+
res.end(JSON.stringify({ ok: false, error: 'The latest version is temporarily unavailable.' }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!before.updateAvailable) {
|
|
215
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
216
|
+
res.end(JSON.stringify({ ...before, updated: false, message: 'Already up to date.' }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
|
|
220
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
221
|
+
res.end(JSON.stringify({
|
|
222
|
+
...before,
|
|
223
|
+
updated: true,
|
|
224
|
+
updatedVersion: before.latestVersion,
|
|
225
|
+
restartRequired: true,
|
|
226
|
+
}));
|
|
227
|
+
} finally {
|
|
228
|
+
installing = false;
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
(ctx.logger || console).warn?.(`[dsh-cron] plugin updater failed: ${String(error)}`);
|
|
232
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
|
|
233
|
+
res.end(JSON.stringify({ ok: false, error: 'Plugin update failed; see server logs.' }));
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.17",
|
|
4
4
|
"description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,13 +11,15 @@
|
|
|
11
11
|
"./package.json": "./package.json"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"lib
|
|
14
|
+
"lib/*.js",
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"README.md",
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "node --test test/*.test.mjs"
|
|
20
|
+
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"build:client": "node scripts/build-client.mjs",
|
|
22
|
+
"pretest": "node scripts/build-client.mjs"
|
|
21
23
|
},
|
|
22
24
|
"keywords": [
|
|
23
25
|
"dsh",
|