@goodandready/dsh-cron 0.2.5 → 0.2.7
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 +144 -6
- package/docs/README.ru.md +147 -5
- package/docs/README.zh.md +144 -6
- package/lib/api.js +533 -0
- package/lib/channels.js +6 -2
- package/lib/client.js +185 -12
- package/lib/config-jobs.js +206 -0
- package/lib/external-api.js +74 -0
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +176 -388
- package/lib/llm-ask.js +141 -0
- package/lib/metrics.js +97 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +373 -192
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +14 -3
- package/lib/task-patch.js +98 -0
- package/lib/task-transfer.js +4 -0
- package/lib/templates.js +4 -0
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -81
- package/docs/plans/0.2.5-ui-block.md +0 -50
package/lib/client.js
CHANGED
|
@@ -36,6 +36,7 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
const ICON_EDIT = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>';
|
|
37
37
|
const ICON_SEND = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
|
|
38
38
|
const ICON_COPY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
39
|
+
const ICON_TUNE = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>';
|
|
39
40
|
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
|
40
41
|
const ICON_UPLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>';
|
|
41
42
|
const ICON_TELEGRAM = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8-1.7 8.01c-.13.58-.47.72-.95.45l-2.6-1.92-1.25 1.21c-.14.14-.26.26-.53.26l.19-2.64 4.81-4.35c.21-.19-.05-.29-.32-.11L8.34 13.5 5.78 12.7c-.56-.17-.57-.56.12-.83l10-3.86c.46-.17.87.11.74.79z"/></svg>';
|
|
@@ -60,6 +61,8 @@ window.__ModuleLoader__.load({
|
|
|
60
61
|
'actions.runNowShort': 'Run',
|
|
61
62
|
'actions.edit': 'Edit / Details',
|
|
62
63
|
'actions.duplicate': 'Duplicate task',
|
|
64
|
+
'actions.tuneWithDsh': 'Tune with DSH',
|
|
65
|
+
'tune.startFailed': 'Could not start the tuning dialogue: {error}',
|
|
63
66
|
'actions.delete': 'Delete task',
|
|
64
67
|
'duplicate.done': 'Copy created and paused: review its schedule before resuming it.',
|
|
65
68
|
'duplicate.oneShotHint': 'The copy is one-shot and paused — its original time has likely passed, so set a new time before resuming.',
|
|
@@ -105,8 +108,11 @@ window.__ModuleLoader__.load({
|
|
|
105
108
|
'list.typeShell': 'Shell',
|
|
106
109
|
'list.typeLlm': 'LLM',
|
|
107
110
|
'list.oneShot': 'One-shot',
|
|
111
|
+
'list.configManaged': 'Config',
|
|
112
|
+
'list.configManagedHint': 'Declared in config.jobs in the profile config file; edit the file to change or remove it',
|
|
108
113
|
'list.next': ' · next: {time}',
|
|
109
114
|
'recs.title': 'Recommended tasks',
|
|
115
|
+
'recs.hint': 'Ready-to-use recipes. Clicking one opens the form with its fields filled in — nothing is created until you save.',
|
|
110
116
|
'modal.editTitle': 'Edit task',
|
|
111
117
|
'modal.newTitle': 'New scheduled task',
|
|
112
118
|
'modal.statusActive': '● Active',
|
|
@@ -170,6 +176,18 @@ window.__ModuleLoader__.load({
|
|
|
170
176
|
'form.providerDefault': 'Default (DSH)',
|
|
171
177
|
'form.modelLabel': 'LLM model',
|
|
172
178
|
'form.modelDefault': 'Default',
|
|
179
|
+
'form.silentRuleLabel': 'Silent rule (optional)',
|
|
180
|
+
'form.silentRulePlaceholder': 'Stay silent when no filesystem is above 80%',
|
|
181
|
+
'form.silentRuleHint': 'For tasks with output: a cheap model judges the run against this rule and the report is skipped when it says to stay silent. If the model is unavailable or unsure, the report is delivered.',
|
|
182
|
+
'history.silentSkip': 'stayed silent:',
|
|
183
|
+
'history.diagnosis': 'Diagnosis',
|
|
184
|
+
'history.applySuggestion': 'Open the edit form with this suggestion',
|
|
185
|
+
'form.inspectOnFailureLabel': 'Diagnose failures with a model',
|
|
186
|
+
'form.inspectOnFailureHint': 'After a failed run a model reads the prompt, the error and the output, and the diagnosis with a proposed prompt change is stored with the run. Nothing is applied automatically.',
|
|
187
|
+
'settings.inspectorModelLabel': 'Model for failure diagnosis',
|
|
188
|
+
'form.fallbackModelLabel': 'Fallback model (optional)',
|
|
189
|
+
'form.fallbackModelPlaceholder': 'e.g. deepseek-reasoner',
|
|
190
|
+
'form.fallbackModelHint': 'If the run fails on the model above, it is retried once on this model. Both attempts are counted in the cost.',
|
|
173
191
|
'form.promptLabelScript': 'Shell command or script',
|
|
174
192
|
'form.promptLabelLlm': 'Instruction / Prompt for the agent',
|
|
175
193
|
'form.promptPlaceholderScript': 'curl -fsSL https://... || exit 1',
|
|
@@ -195,7 +213,7 @@ window.__ModuleLoader__.load({
|
|
|
195
213
|
'form.channelsHint': 'Pick the channels for this task. An explicit selection overrides the Telegram switch above; leave everything unchecked to fall back to it.',
|
|
196
214
|
'form.templateLabel': 'Message template (optional)',
|
|
197
215
|
'form.templatePlaceholder': '⏰ {title} — {status} in {duration}',
|
|
198
|
-
'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}. Empty = built-in text.',
|
|
216
|
+
'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost} {model}. Empty = built-in text.',
|
|
199
217
|
'form.channelTelegram': 'Telegram',
|
|
200
218
|
'form.channelKanban': 'dsh-kanban card',
|
|
201
219
|
'form.channelDiscord': 'Discord',
|
|
@@ -213,6 +231,8 @@ window.__ModuleLoader__.load({
|
|
|
213
231
|
'settings.channelTemplateLabel': 'Template for {channel}',
|
|
214
232
|
'settings.botTokenRefLabel': 'Bot token credential name',
|
|
215
233
|
'settings.botTokenRefPlaceholder': 'e.g. TELEGRAM_BOT_TOKEN',
|
|
234
|
+
'settings.apiTokenLabel': 'External API token',
|
|
235
|
+
'settings.apiTokenPlaceholder': 'Bearer token for /dsh-cron/api/* (empty = disabled)',
|
|
216
236
|
'settings.discordLabel': 'Discord webhook URL',
|
|
217
237
|
'settings.slackLabel': 'Slack webhook URL',
|
|
218
238
|
'settings.ntfyUrlLabel': 'ntfy server URL',
|
|
@@ -229,6 +249,9 @@ window.__ModuleLoader__.load({
|
|
|
229
249
|
'settings.templateLabel': 'Default message template',
|
|
230
250
|
'settings.templateHint': 'Applies to every channel without its own template. Empty = built-in text.',
|
|
231
251
|
'settings.deliveryTimeoutMsLabel': 'Delivery timeout per channel (ms)',
|
|
252
|
+
'settings.automationSection': 'Automation models',
|
|
253
|
+
'settings.automationHint': 'Models used for the small helper calls (rule evaluation, failure analysis). Leave empty to use the task model.',
|
|
254
|
+
'settings.silentRuleModelLabel': 'Model for silent rules',
|
|
232
255
|
'settings.secretsOnCard': 'Credentials referenced from the settings panel are shown masked and stored by name.',
|
|
233
256
|
'form.nextRunLabel': 'Next run: ',
|
|
234
257
|
'form.notScheduled': 'not scheduled',
|
|
@@ -354,6 +377,9 @@ window.__ModuleLoader__.load({
|
|
|
354
377
|
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
355
378
|
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
356
379
|
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
380
|
+
// The external API is the one surface where a typed secret is the point:
|
|
381
|
+
// a CI job cannot present a credential reference (#54).
|
|
382
|
+
{ key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
|
|
357
383
|
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
358
384
|
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
359
385
|
{ key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
|
|
@@ -365,6 +391,8 @@ window.__ModuleLoader__.load({
|
|
|
365
391
|
{ key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
|
|
366
392
|
{ key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
|
|
367
393
|
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
|
|
394
|
+
{ key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
|
|
395
|
+
{ key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
|
|
368
396
|
];
|
|
369
397
|
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
370
398
|
// The global template plus the per-channel override map (server model).
|
|
@@ -436,6 +464,12 @@ window.__ModuleLoader__.load({
|
|
|
436
464
|
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
|
|
437
465
|
),
|
|
438
466
|
]),
|
|
467
|
+
section('automation', 'settings.automationSection', [
|
|
468
|
+
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
|
|
469
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
470
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
|
|
471
|
+
),
|
|
472
|
+
]),
|
|
439
473
|
section('templates', 'settings.templatesSection', [
|
|
440
474
|
React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
|
|
441
475
|
React.createElement('label', null, T('settings.templateLabel')),
|
|
@@ -629,6 +663,7 @@ window.__ModuleLoader__.load({
|
|
|
629
663
|
.dsh-cron-stat-val { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary, #fff); }
|
|
630
664
|
.dsh-cron-stat-lbl { font-size: 12px; color: var(--dsw-alias-label-secondary, #777); margin-top: 0; }
|
|
631
665
|
.dsh-cron-oneshot-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-accent); background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); font-weight: 500; }
|
|
666
|
+
.dsh-cron-config-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #444); color: var(--dsw-alias-label-secondary, #ccc); font-weight: 500; }
|
|
632
667
|
.dsh-cron-cost-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-success); background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); font-weight: 500; }
|
|
633
668
|
.dsh-cron-tokens-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-info); background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); }
|
|
634
669
|
.dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #ccc); cursor: pointer; user-select: none; }
|
|
@@ -778,6 +813,16 @@ window.__ModuleLoader__.load({
|
|
|
778
813
|
if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
|
|
779
814
|
return T_KEY(t, 'history.statusFailed');
|
|
780
815
|
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* A config-declared task is owned by the profile config file (#50): the
|
|
819
|
+
* panel shows it, but editing, pausing or deleting it here would be undone
|
|
820
|
+
* at the next start, so the row keeps the source label instead of the
|
|
821
|
+
* actions that cannot stick.
|
|
822
|
+
*/
|
|
823
|
+
function isConfigManaged(task) {
|
|
824
|
+
return Boolean(task) && task.managedBy === 'config';
|
|
825
|
+
}
|
|
781
826
|
function T_KEY(t, key, vars) {
|
|
782
827
|
return (typeof t === 'function' ? t : translate)(key, vars);
|
|
783
828
|
}
|
|
@@ -815,6 +860,9 @@ window.__ModuleLoader__.load({
|
|
|
815
860
|
const [formPrompt, setFormPrompt] = React.useState('');
|
|
816
861
|
const [formProvider, setFormProvider] = React.useState('');
|
|
817
862
|
const [formModel, setFormModel] = React.useState('');
|
|
863
|
+
const [formFallbackModel, setFormFallbackModel] = React.useState('');
|
|
864
|
+
const [formSilentRule, setFormSilentRule] = React.useState('');
|
|
865
|
+
const [formInspectOnFailure, setFormInspectOnFailure] = React.useState(false);
|
|
818
866
|
const [formDelivery, setFormDelivery] = React.useState('isolated');
|
|
819
867
|
const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
|
|
820
868
|
const [modalTab, setModalTab] = React.useState('params');
|
|
@@ -1138,6 +1186,40 @@ window.__ModuleLoader__.load({
|
|
|
1138
1186
|
}
|
|
1139
1187
|
};
|
|
1140
1188
|
|
|
1189
|
+
/** #49: tune an existing task in a dialogue instead of editing fields. */
|
|
1190
|
+
const handleTuneWithDsh = async (task) => {
|
|
1191
|
+
const lines = [
|
|
1192
|
+
`Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
|
|
1193
|
+
'Its current configuration is:',
|
|
1194
|
+
`- type: ${task.type || 'llm'}`,
|
|
1195
|
+
`- schedule: ${task.scheduleText || task.schedule} (${task.schedule})`,
|
|
1196
|
+
`- status: ${task.status}`,
|
|
1197
|
+
task.model ? `- model: ${task.model}${task.fallbackModel ? `, fallback: ${task.fallbackModel}` : ''}` : null,
|
|
1198
|
+
Array.isArray(task.channels) && task.channels.length ? `- channels: ${task.channels.join(', ')}` : null,
|
|
1199
|
+
`- prompt: ${String(task.prompt || '').slice(0, 400)}`,
|
|
1200
|
+
'',
|
|
1201
|
+
'Ask what should change, then apply it with the cron_update_task tool (read the current state again with cron_get_task if you need the full configuration).',
|
|
1202
|
+
'If the change makes the task execute code (script/node/python/ssh/docker), confirm it with me explicitly before applying it.',
|
|
1203
|
+
].filter(Boolean);
|
|
1204
|
+
try {
|
|
1205
|
+
const res = await fetch('/dsh-cron/chat/start', {
|
|
1206
|
+
method: 'POST',
|
|
1207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1208
|
+
body: JSON.stringify({ prompt: lines.join(String.fromCharCode(10)) })
|
|
1209
|
+
});
|
|
1210
|
+
const data = await res.json();
|
|
1211
|
+
if (!data.ok || !data.sessionId) {
|
|
1212
|
+
alert(T_KEY(t, 'tune.startFailed', { error: (data && data.error) || '' }));
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (onClose) onClose();
|
|
1216
|
+
else toggle.set(false);
|
|
1217
|
+
await openSession(ctx, data.sessionId);
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
alert(T_KEY(t, 'tune.startFailed', { error: err.message }));
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1141
1223
|
const handleDelete = async (id) => {
|
|
1142
1224
|
if (!confirm(T_KEY(t, 'confirm.delete'))) return;
|
|
1143
1225
|
try {
|
|
@@ -1272,10 +1354,21 @@ window.__ModuleLoader__.load({
|
|
|
1272
1354
|
setFormPythonPath('');
|
|
1273
1355
|
setFormSkillName('');
|
|
1274
1356
|
setFormWorkflowName('');
|
|
1357
|
+
// Reset before the preset branch, so a recipe keeps what it sets.
|
|
1358
|
+
setFormFallbackModel('');
|
|
1359
|
+
setFormSilentRule('');
|
|
1360
|
+
setFormInspectOnFailure(false);
|
|
1275
1361
|
if (preset) {
|
|
1276
1362
|
setFormTitle(preset.title || '');
|
|
1277
1363
|
setFormSchedule(preset.schedule || '');
|
|
1278
1364
|
setFormPrompt(preset.prompt || '');
|
|
1365
|
+
// #48: a recipe preset also carries its runtime and delivery shape.
|
|
1366
|
+
if (preset.type) setFormType(preset.type);
|
|
1367
|
+
if (Array.isArray(preset.channels) && preset.channels.length) setFormChannels(preset.channels.filter((c) => DELIVERY_CHANNELS.includes(c)));
|
|
1368
|
+
if (preset.silentRule !== undefined) setFormSilentRule(preset.silentRule || '');
|
|
1369
|
+
if (preset.fallbackModel !== undefined) setFormFallbackModel(preset.fallbackModel || '');
|
|
1370
|
+
if (preset.inspectOnFailure !== undefined) setFormInspectOnFailure(Boolean(preset.inspectOnFailure));
|
|
1371
|
+
setFormOnlyOnFailure(Boolean(preset.onlyOnFailure));
|
|
1279
1372
|
setFormProvider(defaultModelInfo?.provider || '');
|
|
1280
1373
|
setFormModel(defaultModelInfo?.model || '');
|
|
1281
1374
|
if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
|
|
@@ -1305,7 +1398,7 @@ window.__ModuleLoader__.load({
|
|
|
1305
1398
|
}
|
|
1306
1399
|
};
|
|
1307
1400
|
|
|
1308
|
-
const openEditModal = (task) => {
|
|
1401
|
+
const openEditModal = (task, overrides = {}) => {
|
|
1309
1402
|
setDropdownOpen(false);
|
|
1310
1403
|
setFormId(task.id);
|
|
1311
1404
|
setSelectedTaskMeta(task);
|
|
@@ -1313,9 +1406,12 @@ window.__ModuleLoader__.load({
|
|
|
1313
1406
|
setFormTitle(task.title || '');
|
|
1314
1407
|
setFormSchedule(task.schedule || '');
|
|
1315
1408
|
setFormType(task.type || 'llm');
|
|
1316
|
-
setFormPrompt(task.prompt || '');
|
|
1409
|
+
setFormPrompt(overrides.prompt !== undefined ? overrides.prompt : (task.prompt || ''));
|
|
1317
1410
|
setFormProvider(task.provider || '');
|
|
1318
1411
|
setFormModel(task.model || '');
|
|
1412
|
+
setFormFallbackModel(task.fallbackModel || '');
|
|
1413
|
+
setFormSilentRule(task.silentRule || '');
|
|
1414
|
+
setFormInspectOnFailure(Boolean(task.inspectOnFailure));
|
|
1319
1415
|
setFormDelivery(task.delivery || 'isolated');
|
|
1320
1416
|
setFormNotifyTelegram(Boolean(task.notifyTelegram));
|
|
1321
1417
|
setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
|
|
@@ -1372,6 +1468,9 @@ window.__ModuleLoader__.load({
|
|
|
1372
1468
|
delivery: formDelivery || 'isolated',
|
|
1373
1469
|
provider: isAgentType ? (formProvider || undefined) : undefined,
|
|
1374
1470
|
model: isAgentType ? (formModel || undefined) : undefined,
|
|
1471
|
+
fallbackModel: isAgentType ? (formFallbackModel.trim() || undefined) : undefined,
|
|
1472
|
+
silentRule: isAgentType ? undefined : formSilentRule.trim(),
|
|
1473
|
+
inspectOnFailure: isAgentType ? Boolean(formInspectOnFailure) : false,
|
|
1375
1474
|
notifyTelegram: formNotifyTelegram,
|
|
1376
1475
|
channels: formChannels,
|
|
1377
1476
|
template: formTemplate.trim(),
|
|
@@ -1653,18 +1752,19 @@ window.__ModuleLoader__.load({
|
|
|
1653
1752
|
key: task.id,
|
|
1654
1753
|
className: 'dsh-cron-task-item',
|
|
1655
1754
|
'data-task-id': task.id,
|
|
1656
|
-
role: 'button',
|
|
1657
|
-
tabIndex: 0,
|
|
1755
|
+
role: isConfigManaged(task) ? undefined : 'button',
|
|
1756
|
+
tabIndex: isConfigManaged(task) ? undefined : 0,
|
|
1658
1757
|
'aria-label': task.title,
|
|
1659
|
-
|
|
1660
|
-
|
|
1758
|
+
style: isConfigManaged(task) ? { cursor: 'default' } : undefined,
|
|
1759
|
+
onClick: isConfigManaged(task) ? undefined : () => openEditModal(task),
|
|
1760
|
+
onKeyDown: isConfigManaged(task) ? undefined : (e) => {
|
|
1661
1761
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
1662
1762
|
e.preventDefault();
|
|
1663
1763
|
openEditModal(task);
|
|
1664
1764
|
}
|
|
1665
1765
|
}
|
|
1666
1766
|
},
|
|
1667
|
-
React.createElement('button', {
|
|
1767
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1668
1768
|
type: 'button',
|
|
1669
1769
|
className: 'dsh-cron-task-status-btn' + (task.running ? ' dsh-cron-pulse' : ''),
|
|
1670
1770
|
'data-active': task.status === 'active' ? 'true' : undefined,
|
|
@@ -1683,6 +1783,10 @@ window.__ModuleLoader__.load({
|
|
|
1683
1783
|
}, task.type === 'script' ? T_KEY(t, 'list.typeShell') : T_KEY(t, 'list.typeLlm')),
|
|
1684
1784
|
task.model ? React.createElement('span', { className: 'dsh-cron-task-model-tag' }, task.model.split('/').pop()) : null,
|
|
1685
1785
|
task.oneShot ? React.createElement('span', { className: 'dsh-cron-oneshot-tag' }, T_KEY(t, 'list.oneShot')) : null,
|
|
1786
|
+
isConfigManaged(task) ? React.createElement('span', {
|
|
1787
|
+
className: 'dsh-cron-config-tag',
|
|
1788
|
+
title: T_KEY(t, 'list.configManagedHint')
|
|
1789
|
+
}, T_KEY(t, 'list.configManaged')) : null,
|
|
1686
1790
|
task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
|
|
1687
1791
|
task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null
|
|
1688
1792
|
),
|
|
@@ -1708,21 +1812,28 @@ window.__ModuleLoader__.load({
|
|
|
1708
1812
|
title: T_KEY(t, 'actions.runNow'),
|
|
1709
1813
|
onClick: () => handleRunNow(task.id)
|
|
1710
1814
|
}, T_KEY(t, 'actions.runNowShort')),
|
|
1711
|
-
React.createElement('button', {
|
|
1815
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1712
1816
|
type: 'button',
|
|
1713
1817
|
className: 'dsh-cron-icon-btn',
|
|
1714
1818
|
title: T_KEY(t, 'actions.edit'),
|
|
1715
1819
|
'aria-label': T_KEY(t, 'actions.edit'),
|
|
1716
1820
|
onClick: () => openEditModal(task)
|
|
1717
1821
|
}, React.createElement('span', svgProps(ICON_EDIT))),
|
|
1718
|
-
React.createElement('button', {
|
|
1822
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1823
|
+
type: 'button',
|
|
1824
|
+
className: 'dsh-cron-icon-btn',
|
|
1825
|
+
title: T_KEY(t, 'actions.tuneWithDsh'),
|
|
1826
|
+
'aria-label': T_KEY(t, 'actions.tuneWithDsh'),
|
|
1827
|
+
onClick: () => handleTuneWithDsh(task)
|
|
1828
|
+
}, React.createElement('span', svgProps(ICON_TUNE))),
|
|
1829
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1719
1830
|
type: 'button',
|
|
1720
1831
|
className: 'dsh-cron-icon-btn',
|
|
1721
1832
|
title: T_KEY(t, 'actions.duplicate'),
|
|
1722
1833
|
'aria-label': T_KEY(t, 'actions.duplicate'),
|
|
1723
1834
|
onClick: () => handleDuplicate(task)
|
|
1724
1835
|
}, React.createElement('span', svgProps(ICON_COPY))),
|
|
1725
|
-
React.createElement('button', {
|
|
1836
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1726
1837
|
type: 'button',
|
|
1727
1838
|
className: 'dsh-cron-icon-btn dsh-cron-danger',
|
|
1728
1839
|
title: T_KEY(t, 'actions.delete'),
|
|
@@ -1734,6 +1845,7 @@ window.__ModuleLoader__.load({
|
|
|
1734
1845
|
)
|
|
1735
1846
|
),
|
|
1736
1847
|
React.createElement('div', { className: 'dsh-cron-recs-title' }, T_KEY(t, 'recs.title')),
|
|
1848
|
+
React.createElement('div', { className: 'dsh-cron-hint', style: { marginBottom: '8px' } }, T_KEY(t, 'recs.hint')),
|
|
1737
1849
|
React.createElement('div', { className: 'dsh-cron-recs-list' },
|
|
1738
1850
|
recs.map((rec) =>
|
|
1739
1851
|
React.createElement('div', {
|
|
@@ -1820,6 +1932,35 @@ window.__ModuleLoader__.load({
|
|
|
1820
1932
|
React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
|
|
1821
1933
|
)
|
|
1822
1934
|
),
|
|
1935
|
+
// #43: a diagnosed failure shows what went wrong and
|
|
1936
|
+
// offers to prefill the prompt with the suggestion.
|
|
1937
|
+
run.diagnosis && React.createElement('div', {
|
|
1938
|
+
className: 'dsh-cron-status-banner',
|
|
1939
|
+
'data-type': 'info',
|
|
1940
|
+
style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' }
|
|
1941
|
+
},
|
|
1942
|
+
React.createElement('div', null,
|
|
1943
|
+
React.createElement('strong', null, T_KEY(t, 'history.diagnosis')),
|
|
1944
|
+
run.confidence ? ` (${run.confidence})` : ''
|
|
1945
|
+
),
|
|
1946
|
+
React.createElement('div', null, run.diagnosis),
|
|
1947
|
+
run.suggestion && React.createElement('div', null,
|
|
1948
|
+
React.createElement('button', {
|
|
1949
|
+
type: 'button',
|
|
1950
|
+
className: 'dsh-cron-btn-secondary',
|
|
1951
|
+
style: { padding: '2px 8px', fontSize: '12px' },
|
|
1952
|
+
onClick: () => openEditModal(selectedTaskMeta, { prompt: run.suggestion })
|
|
1953
|
+
}, T_KEY(t, 'history.applySuggestion'))
|
|
1954
|
+
)
|
|
1955
|
+
),
|
|
1956
|
+
// #44: a run suppressed by its silent rule says why.
|
|
1957
|
+
run.silentSkip && React.createElement('div', {
|
|
1958
|
+
className: 'dsh-cron-hint',
|
|
1959
|
+
style: { display: 'flex', gap: '6px', alignItems: 'center' }
|
|
1960
|
+
},
|
|
1961
|
+
React.createElement('span', { className: 'dsh-cron-badge-ref' }, T_KEY(t, 'history.silentSkip')),
|
|
1962
|
+
React.createElement('span', null, run.silentReason || '')
|
|
1963
|
+
),
|
|
1823
1964
|
run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
|
|
1824
1965
|
run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error),
|
|
1825
1966
|
run.sessionId && React.createElement('button', {
|
|
@@ -1982,7 +2123,28 @@ window.__ModuleLoader__.load({
|
|
|
1982
2123
|
React.createElement('option', { value: '' }, T_KEY(t, 'form.modelDefault')),
|
|
1983
2124
|
models.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name || m.id))
|
|
1984
2125
|
)
|
|
1985
|
-
)
|
|
2126
|
+
),
|
|
2127
|
+
// #45: one retry on a stronger model when the cheap one fails.
|
|
2128
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2129
|
+
React.createElement('label', null, T_KEY(t, 'form.fallbackModelLabel')),
|
|
2130
|
+
React.createElement('input', {
|
|
2131
|
+
type: 'text',
|
|
2132
|
+
value: formFallbackModel,
|
|
2133
|
+
placeholder: T_KEY(t, 'form.fallbackModelPlaceholder'),
|
|
2134
|
+
onChange: (e) => setFormFallbackModel(e.target.value)
|
|
2135
|
+
}),
|
|
2136
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.fallbackModelHint'))
|
|
2137
|
+
),
|
|
2138
|
+
// #43: diagnose failures with a model.
|
|
2139
|
+
React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '4px' } },
|
|
2140
|
+
React.createElement('input', {
|
|
2141
|
+
type: 'checkbox',
|
|
2142
|
+
checked: formInspectOnFailure,
|
|
2143
|
+
onChange: (e) => setFormInspectOnFailure(e.target.checked)
|
|
2144
|
+
}),
|
|
2145
|
+
React.createElement('span', null, T_KEY(t, 'form.inspectOnFailureLabel'))
|
|
2146
|
+
),
|
|
2147
|
+
formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint'))
|
|
1986
2148
|
),
|
|
1987
2149
|
formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1988
2150
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
@@ -2204,6 +2366,17 @@ window.__ModuleLoader__.load({
|
|
|
2204
2366
|
onChange: (e) => setFormTemplate(e.target.value)
|
|
2205
2367
|
}),
|
|
2206
2368
|
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
|
|
2369
|
+
),
|
|
2370
|
+
// #44: output-producing runtimes may stay silent by rule.
|
|
2371
|
+
!AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
|
|
2372
|
+
React.createElement('label', null, T_KEY(t, 'form.silentRuleLabel')),
|
|
2373
|
+
React.createElement('input', {
|
|
2374
|
+
type: 'text',
|
|
2375
|
+
value: formSilentRule,
|
|
2376
|
+
placeholder: T_KEY(t, 'form.silentRulePlaceholder'),
|
|
2377
|
+
onChange: (e) => setFormSilentRule(e.target.value)
|
|
2378
|
+
}),
|
|
2379
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.silentRuleHint'))
|
|
2207
2380
|
)
|
|
2208
2381
|
),
|
|
2209
2382
|
selectedTaskMeta && React.createElement('div', {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jobs declared in the profile config (#50, ADR-0001).
|
|
3
|
+
*
|
|
4
|
+
* The config file owns the jobs it declares: at startup every declared job is
|
|
5
|
+
* created or updated, and a job that disappears from the config is removed.
|
|
6
|
+
* Tasks created by a user through the UI, the REST API or an agent tool are
|
|
7
|
+
* never touched — when a declared id collides with one of them the entry is
|
|
8
|
+
* skipped and reported, because silently overwriting a user's task is worse
|
|
9
|
+
* than a visible conflict.
|
|
10
|
+
*
|
|
11
|
+
* Entries are validated one by one with a precise message, so one broken job
|
|
12
|
+
* cannot take the plugin down or stop the remaining jobs from syncing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
|
|
16
|
+
import { parseScheduleExpression } from './scheduler.js';
|
|
17
|
+
import { unknownChannelIds } from './channels.js';
|
|
18
|
+
import { PATCHABLE_TASK_FIELDS, validateTaskType } from './task-transfer.js';
|
|
19
|
+
import { pickPatchableFields } from './http-utils.js';
|
|
20
|
+
|
|
21
|
+
/** Marker that makes a task owned by the config (#50). */
|
|
22
|
+
export const MANAGED_BY_CONFIG = 'config';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One message for every surface that refuses to change a config-owned task, so
|
|
26
|
+
* the panel, the REST routes, the tools and the external API all say the same
|
|
27
|
+
* thing about where the task really lives.
|
|
28
|
+
*/
|
|
29
|
+
export function configOwnedMessage(id) {
|
|
30
|
+
return 'Task "' + id + '" is declared in the profile config (config.jobs); change the config file instead';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when a stored task is owned by the config file. */
|
|
34
|
+
export function isConfigOwned(task) {
|
|
35
|
+
return Boolean(task) && task.managedBy === MANAGED_BY_CONFIG;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fields an entry may pass through, minus the ones the sync handles itself:
|
|
40
|
+
* identity, the parsed schedule and the derived one-shot flag.
|
|
41
|
+
*/
|
|
42
|
+
const PASSTHROUGH_FIELDS = PATCHABLE_TASK_FIELDS.filter(
|
|
43
|
+
(key) => !['title', 'schedule', 'prompt', 'type', 'status', 'oneShot'].includes(key)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Task types whose payload is the prompt itself (see validateTaskType).
|
|
48
|
+
* ssh and docker are included because their dedicated fields only describe how
|
|
49
|
+
* to connect or which image to use — without a prompt the runner executes
|
|
50
|
+
* `true`, which reports success while doing nothing. http is excluded on
|
|
51
|
+
* purpose: it accepts the target in httpUrl.
|
|
52
|
+
*/
|
|
53
|
+
const PROMPT_TYPES = ['script', 'node', 'python', 'ssh', 'docker', 'llm', 'skill', 'workflow'];
|
|
54
|
+
|
|
55
|
+
function reject(index, message) {
|
|
56
|
+
return { ok: false, error: 'config.jobs[' + index + ']: ' + message };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate one declared job and turn it into a task record. Returns
|
|
61
|
+
* `{ ok: false, error }` with the entry index so the operator can find it.
|
|
62
|
+
*/
|
|
63
|
+
export function buildConfigJob(entry, index = 0) {
|
|
64
|
+
if (!entry || typeof entry !== 'object') return reject(index, 'entry must be an object');
|
|
65
|
+
|
|
66
|
+
const id = String(entry.id || '').trim();
|
|
67
|
+
if (!id) return reject(index, 'id is required');
|
|
68
|
+
if (!String(entry.title || '').trim()) return reject(index, 'title is required');
|
|
69
|
+
if (!String(entry.schedule || '').trim()) return reject(index, 'schedule is required');
|
|
70
|
+
|
|
71
|
+
const unknown = unknownChannelIds(entry.channels);
|
|
72
|
+
if (unknown.length) return reject(index, 'unknown channel ids: ' + unknown.join(', '));
|
|
73
|
+
|
|
74
|
+
const type = normalizeTaskType(entry.type || 'llm');
|
|
75
|
+
// These types carry their payload in the prompt: a job declared without one
|
|
76
|
+
// would be created, armed and report success while doing nothing.
|
|
77
|
+
if (PROMPT_TYPES.includes(type) && !String(entry.prompt || '').trim()) {
|
|
78
|
+
return reject(index, type + ' jobs require a non-empty prompt (the command or instruction to run)');
|
|
79
|
+
}
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = parseScheduleExpression(entry.schedule);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return reject(index, 'invalid schedule: ' + err.message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const job = {
|
|
88
|
+
...pickPatchableFields(entry, PASSTHROUGH_FIELDS),
|
|
89
|
+
id,
|
|
90
|
+
title: String(entry.title),
|
|
91
|
+
type,
|
|
92
|
+
status: entry.status === 'paused' ? 'paused' : 'active',
|
|
93
|
+
schedule: parsed.cronPattern || String(entry.schedule).trim(),
|
|
94
|
+
scheduleText: parsed.humanText,
|
|
95
|
+
oneShot: Boolean(parsed.isOneShot),
|
|
96
|
+
managedBy: MANAGED_BY_CONFIG,
|
|
97
|
+
};
|
|
98
|
+
if (entry.prompt !== undefined) job.prompt = String(entry.prompt);
|
|
99
|
+
|
|
100
|
+
const typeError = validateTaskType(type, job);
|
|
101
|
+
if (typeError) return reject(index, typeError);
|
|
102
|
+
return { ok: true, job };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Names of the config-owned fields in which the stored task differs from the
|
|
107
|
+
* declared job. An empty list means the stored task is already in sync.
|
|
108
|
+
*/
|
|
109
|
+
export function diffConfigJob(stored, declared) {
|
|
110
|
+
const changed = [];
|
|
111
|
+
for (const key of Object.keys(declared)) {
|
|
112
|
+
if (key === 'id' || key === 'managedBy') continue;
|
|
113
|
+
const next = declared[key];
|
|
114
|
+
const prev = stored ? stored[key] : undefined;
|
|
115
|
+
const same = Array.isArray(next)
|
|
116
|
+
? Array.isArray(prev) && next.length === prev.length && next.every((value, i) => value === prev[i])
|
|
117
|
+
: next === prev;
|
|
118
|
+
if (!same) changed.push(key);
|
|
119
|
+
}
|
|
120
|
+
return changed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Decide what the sync would create, update and remove. Pure, for tests. */
|
|
124
|
+
export function planConfigSync(entries, existingTasks) {
|
|
125
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
126
|
+
const existing = new Map((Array.isArray(existingTasks) ? existingTasks : []).map((task) => [task.id, task]));
|
|
127
|
+
const plan = { create: [], update: [], remove: [], skipped: [], warnings: [] };
|
|
128
|
+
const declaredIds = new Set();
|
|
129
|
+
|
|
130
|
+
list.forEach((entry, index) => {
|
|
131
|
+
// An entry keeps its id "declared" even when the rest of it is invalid:
|
|
132
|
+
// otherwise one typo in a schedule would look like "the job was removed
|
|
133
|
+
// from the config" and the sync would delete the working task.
|
|
134
|
+
const rawId = entry && typeof entry === 'object' && entry.id !== undefined ? String(entry.id).trim() : '';
|
|
135
|
+
if (rawId) declaredIds.add(rawId);
|
|
136
|
+
|
|
137
|
+
const built = buildConfigJob(entry, index);
|
|
138
|
+
if (!built.ok) {
|
|
139
|
+
plan.skipped.push({ index, error: built.error });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const job = built.job;
|
|
143
|
+
declaredIds.add(job.id);
|
|
144
|
+
|
|
145
|
+
const stored = existing.get(job.id);
|
|
146
|
+
if (!stored) {
|
|
147
|
+
plan.create.push(job);
|
|
148
|
+
} else if (stored.managedBy !== MANAGED_BY_CONFIG) {
|
|
149
|
+
plan.skipped.push({
|
|
150
|
+
index,
|
|
151
|
+
id: job.id,
|
|
152
|
+
error: 'config.jobs[' + index + ']: a task with id "' + job.id + '" exists but is not managed by the config; it was left untouched',
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
const changed = diffConfigJob(stored, job);
|
|
156
|
+
if (changed.length) plan.update.push({ job, changed });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (CODE_EXECUTING_TYPES.includes(job.type)) {
|
|
160
|
+
plan.warnings.push('job "' + job.id + '" executes code (' + job.type + '); the config file is its only source');
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
for (const task of existing.values()) {
|
|
165
|
+
if (task.managedBy === MANAGED_BY_CONFIG && !declaredIds.has(task.id)) plan.remove.push(task.id);
|
|
166
|
+
}
|
|
167
|
+
return plan;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Apply the plan: create, update and remove tasks, then arm or pause them.
|
|
172
|
+
* Never throws for a single bad entry — that entry is reported and skipped.
|
|
173
|
+
*/
|
|
174
|
+
export function applyConfigSync({ store, scheduler, entries, log = console }) {
|
|
175
|
+
const plan = planConfigSync(entries, store.list({ status: 'all' }));
|
|
176
|
+
for (const warning of plan.warnings) log.warn('[dsh-cron] config jobs: ' + warning);
|
|
177
|
+
for (const entry of plan.skipped) log.error('[dsh-cron] config jobs: skipped: ' + entry.error);
|
|
178
|
+
|
|
179
|
+
let created = 0;
|
|
180
|
+
let updated = 0;
|
|
181
|
+
let removed = 0;
|
|
182
|
+
|
|
183
|
+
for (const job of plan.create) {
|
|
184
|
+
const task = store.set({ ...job });
|
|
185
|
+
if (task.status === 'active') scheduler.scheduleTask(task);
|
|
186
|
+
created += 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const item of plan.update) {
|
|
190
|
+
const current = store.get(item.job.id);
|
|
191
|
+
const task = store.set({ ...current, ...item.job, id: item.job.id });
|
|
192
|
+
if (task.status === 'active') scheduler.scheduleTask(task);
|
|
193
|
+
else scheduler.pauseTask(task.id);
|
|
194
|
+
updated += 1;
|
|
195
|
+
log.log('[dsh-cron] config job "' + task.id + '" updated: ' + item.changed.join(', '));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const id of plan.remove) {
|
|
199
|
+
scheduler.pauseTask(id);
|
|
200
|
+
store.delete(id);
|
|
201
|
+
removed += 1;
|
|
202
|
+
log.log('[dsh-cron] config job "' + id + '" removed: it is no longer declared');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { created, updated, removed, skipped: plan.skipped.length };
|
|
206
|
+
}
|