@goodandready/dsh-cron 0.2.15 → 0.2.16
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 +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/api.js +3 -1
- package/lib/channels.js +3 -13
- package/lib/chat-start.js +6 -2
- package/lib/client.js +111 -0
- 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 +1 -1
package/README.md
CHANGED
|
@@ -392,6 +392,13 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
|
|
|
392
392
|
|
|
393
393
|
|
|
394
394
|
|
|
395
|
+
### 28. In-App One-Click Auto-Updater & Error Resilience (v0.2.16, #147, #155, #156)
|
|
396
|
+
- **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`).
|
|
397
|
+
- **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.
|
|
398
|
+
- **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.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
395
402
|
## 📦 Installation
|
|
396
403
|
|
|
397
404
|
Install into your DeepSeek Harness web profile:
|
|
@@ -503,6 +510,7 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
|
|
|
503
510
|
| `POST` | `/dsh-cron/kanban/test` | Create a Kanban connectivity-test card |
|
|
504
511
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy alias for the task action routes (`run`, `toggle`, `delete`, `history`) |
|
|
505
512
|
| `GET` | `/dsh-cron/metrics` | Prometheus text exposition of task and run counters — never prompts or output (#53) |
|
|
513
|
+
| `GET` / `POST` | `/api/dsh-cron/update` | One-click plugin self-updater: query registry version and in-place upgrade (#147) |
|
|
506
514
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | External token-guarded surface: list / create-or-update (#54) |
|
|
507
515
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | External token-guarded surface: read / delete (#54) |
|
|
508
516
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | External token-guarded surface: force a run (#54) |
|
package/README.ru.md
CHANGED
|
@@ -268,6 +268,13 @@ cron_create_task({
|
|
|
268
268
|
|
|
269
269
|
|
|
270
270
|
|
|
271
|
+
### 28. Модуль самообновления в 1 клик и устойчивость к ошибкам (v0.2.16, #147, #155, #156)
|
|
272
|
+
- **Самообновление плагина (#147)**: Добавлен встроенный модуль обновления (`lib/updater.js`) с маршрутом `/api/dsh-cron/update` и карточкой в настройках Web UI. Автоматически опрашивает npm registry, корректно сравнивает semver-версии (включая пререлизы) и обновляет `@goodandready/dsh-cron` на лету через CLI DSH без необходимости входа по SSH. Запросы POST защищены проверкой источника (`rejectCrossOrigin`).
|
|
273
|
+
- **Устранение скрытых сбоев (#156)**: Ликвидированы пустые `catch`: незавершённые откаты транзакций импорта логируются с уровнем `warn`, причины fallback для динамических модулей ядра фиксируются в отладочном журнале, а при сбое открытия сессии пользователь получает понятное уведомление.
|
|
274
|
+
- **Гигиена кода и экспортов (#155)**: Удалены устаревшие неиспользуемые сущности (`CHANNEL_LABELS`, `makeInspectAsk`), снят лишний модификатор `export` с 16 внутренних функций и констант, а метод `supportsSilentRule` напрямую подключен в боевой пайплайн проверки тихих правил.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
271
278
|
## 📦 Установка
|
|
272
279
|
|
|
273
280
|
```bash
|
|
@@ -500,6 +507,7 @@ bash deploy.sh verify [exact-version]
|
|
|
500
507
|
| `POST` | `/dsh-cron/kanban/test` | Тестовая карточка в Kanban |
|
|
501
508
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy-алиас действий над задачей (`run`, `toggle`, `delete`, `history`) |
|
|
502
509
|
| `GET` | `/dsh-cron/metrics` | Текст в формате Prometheus: счётчики задач и запусков — без промптов и вывода (#53) |
|
|
510
|
+
| `GET` / `POST` | `/api/dsh-cron/update` | Самообновление плагина в 1 клик: запрос версии в реестре и обновление на лету (#147) |
|
|
503
511
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | Внешняя поверхность под токеном: список / создание-обновление (#54) |
|
|
504
512
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | Внешняя поверхность под токеном: чтение / удаление (#54) |
|
|
505
513
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | Внешняя поверхность под токеном: принудительный запуск (#54) |
|
package/README.zh.md
CHANGED
|
@@ -391,6 +391,13 @@ bash deploy.sh verify [exact-version]
|
|
|
391
391
|
|
|
392
392
|
|
|
393
393
|
|
|
394
|
+
### 28. 应用内一键自更新与错误容错增强 (v0.2.16, #147, #155, #156)
|
|
395
|
+
- **插件自更新模块 (#147)**:新增应用内一键自更新机制 (`lib/updater.js`)、`/api/dsh-cron/update` 接口与设置面板专属卡片。自动轮询 npmjs 仓库最新版本,精准比对 Semver 版本号(含预发布版本),通过 DSH CLI 就地升级 `@goodandready/dsh-cron`,无需 SSH 终端操作。POST 请求受跨域与来源保护 (`rejectCrossOrigin`)。
|
|
396
|
+
- **消除静默异常与错误追踪 (#156)**:全面消除空 `catch` 异常压制:任务导入事务回滚失败记录 `warn` 级别日志、动态核心模块降级原因记录诊断日志、会话自动唤出失败向用户呈现友好提示。
|
|
397
|
+
- **死代码清理与模块导出精简 (#155)**:清理未引用的旧版实体 (`CHANNEL_LABELS`, `makeInspectAsk`),收回 16 个内部工具函数的暴露权限,并将 `supportsSilentRule` 接入静默规则核心执行流水线。
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
394
401
|
## 📦 安装
|
|
395
402
|
|
|
396
403
|
```bash
|
|
@@ -500,6 +507,7 @@ dsh-cron:
|
|
|
500
507
|
| `POST` | `/dsh-cron/kanban/test` | 创建 Kanban 连通性测试卡片 |
|
|
501
508
|
| `*` | `/dsh-cron/action/:id/:action` | 任务操作路由的兼容别名(`run`、`toggle`、`delete`、`history`) |
|
|
502
509
|
| `GET` | `/dsh-cron/metrics` | Prometheus 文本格式的任务与运行计数 —— 不含提示词与输出(#53) |
|
|
510
|
+
| `GET` / `POST` | `/api/dsh-cron/update` | 插件一键自更新:查询仓库最新版本并就地平滑升级 (#147) |
|
|
503
511
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | 令牌保护的外部接口:列表 / 创建或更新(#54) |
|
|
504
512
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | 令牌保护的外部接口:读取 / 删除(#54) |
|
|
505
513
|
| `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
|
@@ -1088,6 +1088,7 @@ window.__ModuleLoader__.load({
|
|
|
1088
1088
|
}
|
|
1089
1089
|
}
|
|
1090
1090
|
}
|
|
1091
|
+
console.warn('[dsh-cron] could not open session in workspace:', sessionId);
|
|
1091
1092
|
return false;
|
|
1092
1093
|
}
|
|
1093
1094
|
|
|
@@ -3316,6 +3317,65 @@ window.__ModuleLoader__.load({
|
|
|
3316
3317
|
const [testInfo, setTestInfo] = React.useState(null);
|
|
3317
3318
|
// Collapsed by default, head is the toggle (canonical card contract, #100)
|
|
3318
3319
|
const [cardOpen, setCardOpen] = React.useState(false);
|
|
3320
|
+
const [updateState, setUpdateState] = React.useState({
|
|
3321
|
+
checking: false,
|
|
3322
|
+
updating: false,
|
|
3323
|
+
currentVersion: '0.2.16',
|
|
3324
|
+
latestVersion: undefined,
|
|
3325
|
+
updateAvailable: false,
|
|
3326
|
+
notice: null,
|
|
3327
|
+
error: null
|
|
3328
|
+
});
|
|
3329
|
+
|
|
3330
|
+
const checkUpdates = async () => {
|
|
3331
|
+
setUpdateState(prev => ({ ...prev, checking: true, error: null, notice: null }));
|
|
3332
|
+
try {
|
|
3333
|
+
const res = await fetch('/api/dsh-cron/update');
|
|
3334
|
+
const data = await res.json();
|
|
3335
|
+
if (data && data.currentVersion) {
|
|
3336
|
+
setUpdateState(prev => ({
|
|
3337
|
+
...prev,
|
|
3338
|
+
checking: false,
|
|
3339
|
+
currentVersion: data.currentVersion,
|
|
3340
|
+
latestVersion: data.latestVersion,
|
|
3341
|
+
updateAvailable: Boolean(data.updateAvailable),
|
|
3342
|
+
error: data.latestCheckFailed ? 'Registry check failed' : null
|
|
3343
|
+
}));
|
|
3344
|
+
} else {
|
|
3345
|
+
setUpdateState(prev => ({ ...prev, checking: false, error: data.error || 'Check failed' }));
|
|
3346
|
+
}
|
|
3347
|
+
} catch (err) {
|
|
3348
|
+
setUpdateState(prev => ({ ...prev, checking: false, error: err.message }));
|
|
3349
|
+
}
|
|
3350
|
+
};
|
|
3351
|
+
|
|
3352
|
+
const runUpdate = async () => {
|
|
3353
|
+
setUpdateState(prev => ({ ...prev, updating: true, error: null, notice: null }));
|
|
3354
|
+
try {
|
|
3355
|
+
const res = await fetch('/api/dsh-cron/update', {
|
|
3356
|
+
method: 'POST',
|
|
3357
|
+
headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' }
|
|
3358
|
+
});
|
|
3359
|
+
const data = await res.json();
|
|
3360
|
+
if (data && data.updated) {
|
|
3361
|
+
setUpdateState(prev => ({
|
|
3362
|
+
...prev,
|
|
3363
|
+
updating: false,
|
|
3364
|
+
updateAvailable: false,
|
|
3365
|
+
currentVersion: data.updatedVersion || data.latestVersion,
|
|
3366
|
+
notice: T_KEY(t, 'updater.success', { version: data.updatedVersion || data.latestVersion })
|
|
3367
|
+
}));
|
|
3368
|
+
} else {
|
|
3369
|
+
setUpdateState(prev => ({
|
|
3370
|
+
...prev,
|
|
3371
|
+
updating: false,
|
|
3372
|
+
error: data.error || data.message || 'Update failed'
|
|
3373
|
+
}));
|
|
3374
|
+
}
|
|
3375
|
+
} catch (err) {
|
|
3376
|
+
setUpdateState(prev => ({ ...prev, updating: false, error: err.message }));
|
|
3377
|
+
}
|
|
3378
|
+
};
|
|
3319
3379
|
|
|
3320
3380
|
// Settings snapshot status (#102): never render phantom inputs before
|
|
3321
3381
|
// the snapshot arrives; offer a retry when it is unavailable.
|
|
@@ -3521,6 +3581,57 @@ window.__ModuleLoader__.load({
|
|
|
3521
3581
|
'data-type': testInfo.success ? 'success' : 'error',
|
|
3522
3582
|
style: { margin: 0, padding: '8px 12px' }
|
|
3523
3583
|
}, testInfo.message),
|
|
3584
|
+
React.createElement('div', {
|
|
3585
|
+
className: 'dsh-cron-form-group',
|
|
3586
|
+
style: {
|
|
3587
|
+
background: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03))',
|
|
3588
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
|
|
3589
|
+
borderRadius: '8px',
|
|
3590
|
+
padding: '14px',
|
|
3591
|
+
marginBottom: '16px'
|
|
3592
|
+
}
|
|
3593
|
+
},
|
|
3594
|
+
React.createElement('div', {
|
|
3595
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }
|
|
3596
|
+
},
|
|
3597
|
+
React.createElement('div', { style: { fontWeight: '600', fontSize: '14px' } }, `🚀 ${T_KEY(t, 'updater.title')}`),
|
|
3598
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
3599
|
+
React.createElement('button', {
|
|
3600
|
+
type: 'button',
|
|
3601
|
+
className: 'dsh-cron-btn-secondary',
|
|
3602
|
+
style: { padding: '4px 10px', fontSize: '12px' },
|
|
3603
|
+
disabled: updateState.checking || updateState.updating,
|
|
3604
|
+
onClick: checkUpdates
|
|
3605
|
+
}, updateState.checking ? T_KEY(t, 'updater.checking') : T_KEY(t, 'updater.btnCheck')),
|
|
3606
|
+
updateState.updateAvailable ? React.createElement('button', {
|
|
3607
|
+
type: 'button',
|
|
3608
|
+
className: 'dsh-cron-btn-primary',
|
|
3609
|
+
style: { padding: '4px 10px', fontSize: '12px' },
|
|
3610
|
+
disabled: updateState.updating,
|
|
3611
|
+
onClick: runUpdate
|
|
3612
|
+
}, updateState.updating ? T_KEY(t, 'updater.updating') : T_KEY(t, 'updater.btnUpdate', { version: updateState.latestVersion })) : null
|
|
3613
|
+
)
|
|
3614
|
+
),
|
|
3615
|
+
React.createElement('div', {
|
|
3616
|
+
style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #999)', marginBottom: '8px' }
|
|
3617
|
+
}, T_KEY(t, 'updater.desc')),
|
|
3618
|
+
React.createElement('div', {
|
|
3619
|
+
style: { display: 'flex', alignItems: 'center', gap: '12px', fontSize: '13px' }
|
|
3620
|
+
},
|
|
3621
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #aaa)' } },
|
|
3622
|
+
T_KEY(t, 'updater.current', { version: updateState.currentVersion })
|
|
3623
|
+
),
|
|
3624
|
+
updateState.checking ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-info' }, T_KEY(t, 'updater.checking')) :
|
|
3625
|
+
updateState.updateAvailable ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-warn' }, T_KEY(t, 'updater.available', { version: updateState.latestVersion })) :
|
|
3626
|
+
updateState.currentVersion && !updateState.error ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-ok' }, `✓ ${T_KEY(t, 'updater.upToDate')}`) : null
|
|
3627
|
+
),
|
|
3628
|
+
updateState.notice ? React.createElement('div', {
|
|
3629
|
+
style: { marginTop: '8px', color: 'var(--dsw-alias-success, #10b981)', fontSize: '12px' }
|
|
3630
|
+
}, updateState.notice) : null,
|
|
3631
|
+
updateState.error ? React.createElement('div', {
|
|
3632
|
+
style: { marginTop: '8px', color: 'var(--dsw-alias-error, #ef4444)', fontSize: '12px' }
|
|
3633
|
+
}, updateState.error) : null
|
|
3634
|
+
),
|
|
3524
3635
|
React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '4px' } },
|
|
3525
3636
|
React.createElement('button', {
|
|
3526
3637
|
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
|
+
}
|