@goodandready/dsh-cron 0.2.6 → 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 +126 -0
- package/docs/README.ru.md +126 -0
- package/docs/README.zh.md +126 -0
- package/lib/api.js +138 -4
- package/lib/channels.js +6 -2
- package/lib/client.js +32 -9
- package/lib/config-jobs.js +206 -0
- package/lib/external-api.js +74 -0
- package/lib/index.js +55 -0
- package/lib/metrics.js +97 -0
- package/lib/scheduler.js +148 -113
- package/lib/store.js +3 -3
- package/lib/task-patch.js +17 -0
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -83
- package/docs/plans/0.2.5-ui-block.md +0 -50
- package/docs/plans/0.2.6-economy-block.md +0 -61
package/lib/api.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { parseScheduleExpression } from './scheduler.js';
|
|
10
10
|
import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
|
|
11
|
-
import { CHANNEL_IDS } from './channels.js';
|
|
11
|
+
import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
|
|
12
12
|
import { applyTaskPatch } from './task-patch.js';
|
|
13
|
+
import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
|
|
13
14
|
import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER } from './http-utils.js';
|
|
14
15
|
import {
|
|
15
16
|
validateTaskType,
|
|
@@ -26,6 +27,15 @@ import {
|
|
|
26
27
|
const NOT_FOUND = { ok: false, error: 'Task not found' };
|
|
27
28
|
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* A task declared in the profile config is owned by that file (#50): the next
|
|
32
|
+
* plugin start would revert any change made here, so every mutating route
|
|
33
|
+
* refuses it and says where the task really lives.
|
|
34
|
+
*/
|
|
35
|
+
function refuseConfigOwned(res, id) {
|
|
36
|
+
sendJson(res, 409, { ok: false, error: configOwnedMessage(id) });
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
/**
|
|
30
40
|
* Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
|
|
31
41
|
* route families — the #66 alias is kept alive by the same code path instead
|
|
@@ -108,6 +118,15 @@ async function createOrUpdateTask({ store, scheduler, req, res }) {
|
|
|
108
118
|
sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
|
|
109
119
|
return;
|
|
110
120
|
}
|
|
121
|
+
const unknownChannels = unknownChannelIds(body.channels);
|
|
122
|
+
if (unknownChannels.length) {
|
|
123
|
+
sendJson(res, 400, {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: 'Unknown channel ids: ' + unknownChannels.join(', '),
|
|
126
|
+
unknownChannels,
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
111
130
|
const taskType = normalizeTaskType(body.type);
|
|
112
131
|
if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
|
|
113
132
|
sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
|
|
@@ -126,6 +145,16 @@ async function createOrUpdateTask({ store, scheduler, req, res }) {
|
|
|
126
145
|
sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
|
|
127
146
|
return;
|
|
128
147
|
}
|
|
148
|
+
// Create-or-update with an existing id is an update too, so the config-owned
|
|
149
|
+
// refusal must apply here as well — otherwise the POST route would be a way
|
|
150
|
+
// around the guard on PATCH (#50 review finding).
|
|
151
|
+
if (body.id) {
|
|
152
|
+
const existing = store.get(body.id);
|
|
153
|
+
if (existing && existing.managedBy === MANAGED_BY_CONFIG) {
|
|
154
|
+
refuseConfigOwned(res, body.id);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
129
158
|
const task = store.set(buildTaskRecord(store, body, taskType, parsed));
|
|
130
159
|
if (task.status === 'active') {
|
|
131
160
|
scheduler.scheduleTask(task);
|
|
@@ -192,8 +221,36 @@ async function handleTaskImport({ store, scheduler, req, res }) {
|
|
|
192
221
|
});
|
|
193
222
|
return;
|
|
194
223
|
}
|
|
224
|
+
// #121: an imported file may name a channel this build no longer has. The
|
|
225
|
+
// import stays tolerant by design (#42), but the loss is reported instead of
|
|
226
|
+
// being invisible.
|
|
227
|
+
const rawTasks = Array.isArray(body.document && body.document.tasks) ? body.document.tasks : [];
|
|
228
|
+
const droppedChannels = [];
|
|
229
|
+
for (const item of rawTasks) {
|
|
230
|
+
for (const id of unknownChannelIds(item.channels)) {
|
|
231
|
+
if (!droppedChannels.includes(id)) droppedChannels.push(id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (droppedChannels.length) {
|
|
235
|
+
console.warn('[dsh-cron] import: unknown channel ids were dropped: ' + droppedChannels.join(', '));
|
|
236
|
+
}
|
|
195
237
|
const existingIds = store.list({ status: 'all' }).map((x) => x.id);
|
|
196
238
|
const plan = planImport(checked.tasks, existingIds, strategy);
|
|
239
|
+
// Replacing a task the config owns would be undone at the next start, and
|
|
240
|
+
// the id could then hold two different tasks (#50).
|
|
241
|
+
const configConflicts = plan.replace
|
|
242
|
+
.map((item) => item.id)
|
|
243
|
+
.filter((taskId) => {
|
|
244
|
+
const stored = store.get(taskId);
|
|
245
|
+
return stored && stored.managedBy === MANAGED_BY_CONFIG;
|
|
246
|
+
});
|
|
247
|
+
if (configConflicts.length) {
|
|
248
|
+
sendJson(res, 409, {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: 'These tasks are declared in the profile config and cannot be replaced by an import: ' + configConflicts.join(', '),
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
197
254
|
const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
|
|
198
255
|
if (body && body.dryRun) {
|
|
199
256
|
sendJson(res, 200, {
|
|
@@ -202,6 +259,7 @@ async function handleTaskImport({ store, scheduler, req, res }) {
|
|
|
202
259
|
strategy: plan.strategy,
|
|
203
260
|
summary,
|
|
204
261
|
codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
|
|
262
|
+
unknownChannels: droppedChannels,
|
|
205
263
|
});
|
|
206
264
|
return;
|
|
207
265
|
}
|
|
@@ -210,7 +268,13 @@ async function handleTaskImport({ store, scheduler, req, res }) {
|
|
|
210
268
|
sendJson(res, 500, { ok: false, error: applied.error });
|
|
211
269
|
return;
|
|
212
270
|
}
|
|
213
|
-
sendJson(res, 200, {
|
|
271
|
+
sendJson(res, 200, {
|
|
272
|
+
ok: true,
|
|
273
|
+
imported: applied.imported.length,
|
|
274
|
+
summary,
|
|
275
|
+
unknownChannels: droppedChannels,
|
|
276
|
+
tasks: applied.imported,
|
|
277
|
+
});
|
|
214
278
|
}
|
|
215
279
|
|
|
216
280
|
/**
|
|
@@ -309,6 +373,10 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
|
|
|
309
373
|
return true;
|
|
310
374
|
}
|
|
311
375
|
if (action === 'pause' || action === 'resume') {
|
|
376
|
+
if (store.get(id) && store.get(id).managedBy === MANAGED_BY_CONFIG) {
|
|
377
|
+
refuseConfigOwned(res, id);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
312
380
|
const task = action === 'pause' ? scheduler.pauseTask(id) : scheduler.resumeTask(id);
|
|
313
381
|
if (!task) {
|
|
314
382
|
sendJson(res, 404, NOT_FOUND);
|
|
@@ -318,10 +386,15 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
|
|
|
318
386
|
return true;
|
|
319
387
|
}
|
|
320
388
|
if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
|
|
321
|
-
|
|
389
|
+
const stored = store.get(id);
|
|
390
|
+
if (!stored) {
|
|
322
391
|
sendJson(res, 404, NOT_FOUND);
|
|
323
392
|
return true;
|
|
324
393
|
}
|
|
394
|
+
if (stored.managedBy === MANAGED_BY_CONFIG) {
|
|
395
|
+
refuseConfigOwned(res, id);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
325
398
|
const task = scheduler.toggleTask(id);
|
|
326
399
|
sendJson(res, 200, { ok: true, task });
|
|
327
400
|
return true;
|
|
@@ -335,6 +408,10 @@ async function patchTask({ store, scheduler, req, res, id }) {
|
|
|
335
408
|
sendJson(res, 404, NOT_FOUND);
|
|
336
409
|
return;
|
|
337
410
|
}
|
|
411
|
+
if (current.managedBy === MANAGED_BY_CONFIG) {
|
|
412
|
+
refuseConfigOwned(res, id);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
338
415
|
const { body, error } = await readBody(req, res);
|
|
339
416
|
if (error) return;
|
|
340
417
|
const result = applyTaskPatch({
|
|
@@ -349,7 +426,11 @@ async function patchTask({ store, scheduler, req, res, id }) {
|
|
|
349
426
|
const error = result.needsConfirmation
|
|
350
427
|
? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
|
|
351
428
|
: result.error;
|
|
352
|
-
sendJson(res, status, {
|
|
429
|
+
sendJson(res, status, {
|
|
430
|
+
ok: false,
|
|
431
|
+
error,
|
|
432
|
+
...(result.unknownChannels ? { unknownChannels: result.unknownChannels } : {}),
|
|
433
|
+
});
|
|
353
434
|
return;
|
|
354
435
|
}
|
|
355
436
|
sendJson(res, 200, { ok: true, task: result.task });
|
|
@@ -357,6 +438,10 @@ async function patchTask({ store, scheduler, req, res, id }) {
|
|
|
357
438
|
|
|
358
439
|
function deleteTask({ store, scheduler, res, id }) {
|
|
359
440
|
const current = store.get(id);
|
|
441
|
+
if (current && current.managedBy === MANAGED_BY_CONFIG) {
|
|
442
|
+
refuseConfigOwned(res, id);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
360
445
|
if (current) {
|
|
361
446
|
scheduler.pauseTask(id);
|
|
362
447
|
store.delete(id);
|
|
@@ -397,3 +482,52 @@ function mergeExecutionFields(current, body, parsed) {
|
|
|
397
482
|
oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
|
|
398
483
|
};
|
|
399
484
|
}
|
|
485
|
+
|
|
486
|
+
// --------------------------------------------------------- external surface
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Task operations for the token-guarded /dsh-cron/api/* prefix (#54). The shape
|
|
490
|
+
* is deliberately small — list, read, create or update, delete, manual run —
|
|
491
|
+
* and reuses the handlers above, so the confirmation gate for code-executing
|
|
492
|
+
* tasks and the config-owned refusals are the same code, not a second copy.
|
|
493
|
+
*/
|
|
494
|
+
export async function handleExternalTaskRequest({ store, scheduler, req, res, url }) {
|
|
495
|
+
const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, tasks, :id?, :action?
|
|
496
|
+
const id = parts[3];
|
|
497
|
+
const action = parts[4];
|
|
498
|
+
|
|
499
|
+
if (!id) {
|
|
500
|
+
if (req.method === 'GET') {
|
|
501
|
+
const list = store
|
|
502
|
+
.list({ status: url.searchParams.get('status') || 'all', query: url.searchParams.get('query') || '' })
|
|
503
|
+
.map((task) => ({ ...task, running: scheduler.isRunning(task.id) }));
|
|
504
|
+
sendJson(res, 200, { ok: true, tasks: list });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (req.method === 'POST') {
|
|
508
|
+
await createOrUpdateTask({ store, scheduler, req, res });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (req.method === 'POST') {
|
|
516
|
+
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
|
|
517
|
+
if (handled) return;
|
|
518
|
+
}
|
|
519
|
+
if (req.method === 'GET' && !action) {
|
|
520
|
+
const task = store.get(id);
|
|
521
|
+
if (!task) {
|
|
522
|
+
sendJson(res, 404, NOT_FOUND);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
sendJson(res, 200, { ok: true, task: { ...task, running: scheduler.isRunning(id) } });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (req.method === 'DELETE' && !action) {
|
|
529
|
+
deleteTask({ store, scheduler, res, id });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
533
|
+
}
|
package/lib/channels.js
CHANGED
|
@@ -56,9 +56,13 @@ export function resolveChannels(task, settings = {}) {
|
|
|
56
56
|
* reported explicitly — a silently dropped notification is worse than a
|
|
57
57
|
* visible failure.
|
|
58
58
|
*/
|
|
59
|
+
export function unknownChannelIds(list) {
|
|
60
|
+
if (!Array.isArray(list)) return [];
|
|
61
|
+
return list.filter((id) => !CHANNEL_IDS.includes(id));
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
export function unknownChannels(task) {
|
|
60
|
-
|
|
61
|
-
return task.channels.filter((id) => !CHANNEL_IDS.includes(id));
|
|
65
|
+
return unknownChannelIds(task && task.channels);
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/** Failure/only-on-failure filtering, per channel. */
|
package/lib/client.js
CHANGED
|
@@ -108,6 +108,8 @@ window.__ModuleLoader__.load({
|
|
|
108
108
|
'list.typeShell': 'Shell',
|
|
109
109
|
'list.typeLlm': 'LLM',
|
|
110
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',
|
|
111
113
|
'list.next': ' · next: {time}',
|
|
112
114
|
'recs.title': 'Recommended tasks',
|
|
113
115
|
'recs.hint': 'Ready-to-use recipes. Clicking one opens the form with its fields filled in — nothing is created until you save.',
|
|
@@ -229,6 +231,8 @@ window.__ModuleLoader__.load({
|
|
|
229
231
|
'settings.channelTemplateLabel': 'Template for {channel}',
|
|
230
232
|
'settings.botTokenRefLabel': 'Bot token credential name',
|
|
231
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)',
|
|
232
236
|
'settings.discordLabel': 'Discord webhook URL',
|
|
233
237
|
'settings.slackLabel': 'Slack webhook URL',
|
|
234
238
|
'settings.ntfyUrlLabel': 'ntfy server URL',
|
|
@@ -373,6 +377,9 @@ window.__ModuleLoader__.load({
|
|
|
373
377
|
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
374
378
|
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
375
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' },
|
|
376
383
|
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
377
384
|
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
378
385
|
{ key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
|
|
@@ -656,6 +663,7 @@ window.__ModuleLoader__.load({
|
|
|
656
663
|
.dsh-cron-stat-val { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary, #fff); }
|
|
657
664
|
.dsh-cron-stat-lbl { font-size: 12px; color: var(--dsw-alias-label-secondary, #777); margin-top: 0; }
|
|
658
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; }
|
|
659
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; }
|
|
660
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); }
|
|
661
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; }
|
|
@@ -805,6 +813,16 @@ window.__ModuleLoader__.load({
|
|
|
805
813
|
if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
|
|
806
814
|
return T_KEY(t, 'history.statusFailed');
|
|
807
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
|
+
}
|
|
808
826
|
function T_KEY(t, key, vars) {
|
|
809
827
|
return (typeof t === 'function' ? t : translate)(key, vars);
|
|
810
828
|
}
|
|
@@ -1734,18 +1752,19 @@ window.__ModuleLoader__.load({
|
|
|
1734
1752
|
key: task.id,
|
|
1735
1753
|
className: 'dsh-cron-task-item',
|
|
1736
1754
|
'data-task-id': task.id,
|
|
1737
|
-
role: 'button',
|
|
1738
|
-
tabIndex: 0,
|
|
1755
|
+
role: isConfigManaged(task) ? undefined : 'button',
|
|
1756
|
+
tabIndex: isConfigManaged(task) ? undefined : 0,
|
|
1739
1757
|
'aria-label': task.title,
|
|
1740
|
-
|
|
1741
|
-
|
|
1758
|
+
style: isConfigManaged(task) ? { cursor: 'default' } : undefined,
|
|
1759
|
+
onClick: isConfigManaged(task) ? undefined : () => openEditModal(task),
|
|
1760
|
+
onKeyDown: isConfigManaged(task) ? undefined : (e) => {
|
|
1742
1761
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
1743
1762
|
e.preventDefault();
|
|
1744
1763
|
openEditModal(task);
|
|
1745
1764
|
}
|
|
1746
1765
|
}
|
|
1747
1766
|
},
|
|
1748
|
-
React.createElement('button', {
|
|
1767
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1749
1768
|
type: 'button',
|
|
1750
1769
|
className: 'dsh-cron-task-status-btn' + (task.running ? ' dsh-cron-pulse' : ''),
|
|
1751
1770
|
'data-active': task.status === 'active' ? 'true' : undefined,
|
|
@@ -1764,6 +1783,10 @@ window.__ModuleLoader__.load({
|
|
|
1764
1783
|
}, task.type === 'script' ? T_KEY(t, 'list.typeShell') : T_KEY(t, 'list.typeLlm')),
|
|
1765
1784
|
task.model ? React.createElement('span', { className: 'dsh-cron-task-model-tag' }, task.model.split('/').pop()) : null,
|
|
1766
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,
|
|
1767
1790
|
task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
|
|
1768
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
|
|
1769
1792
|
),
|
|
@@ -1789,28 +1812,28 @@ window.__ModuleLoader__.load({
|
|
|
1789
1812
|
title: T_KEY(t, 'actions.runNow'),
|
|
1790
1813
|
onClick: () => handleRunNow(task.id)
|
|
1791
1814
|
}, T_KEY(t, 'actions.runNowShort')),
|
|
1792
|
-
React.createElement('button', {
|
|
1815
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1793
1816
|
type: 'button',
|
|
1794
1817
|
className: 'dsh-cron-icon-btn',
|
|
1795
1818
|
title: T_KEY(t, 'actions.edit'),
|
|
1796
1819
|
'aria-label': T_KEY(t, 'actions.edit'),
|
|
1797
1820
|
onClick: () => openEditModal(task)
|
|
1798
1821
|
}, React.createElement('span', svgProps(ICON_EDIT))),
|
|
1799
|
-
React.createElement('button', {
|
|
1822
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1800
1823
|
type: 'button',
|
|
1801
1824
|
className: 'dsh-cron-icon-btn',
|
|
1802
1825
|
title: T_KEY(t, 'actions.tuneWithDsh'),
|
|
1803
1826
|
'aria-label': T_KEY(t, 'actions.tuneWithDsh'),
|
|
1804
1827
|
onClick: () => handleTuneWithDsh(task)
|
|
1805
1828
|
}, React.createElement('span', svgProps(ICON_TUNE))),
|
|
1806
|
-
React.createElement('button', {
|
|
1829
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1807
1830
|
type: 'button',
|
|
1808
1831
|
className: 'dsh-cron-icon-btn',
|
|
1809
1832
|
title: T_KEY(t, 'actions.duplicate'),
|
|
1810
1833
|
'aria-label': T_KEY(t, 'actions.duplicate'),
|
|
1811
1834
|
onClick: () => handleDuplicate(task)
|
|
1812
1835
|
}, React.createElement('span', svgProps(ICON_COPY))),
|
|
1813
|
-
React.createElement('button', {
|
|
1836
|
+
isConfigManaged(task) ? null : React.createElement('button', {
|
|
1814
1837
|
type: 'button',
|
|
1815
1838
|
className: 'dsh-cron-icon-btn dsh-cron-danger',
|
|
1816
1839
|
title: T_KEY(t, 'actions.delete'),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External REST surface under /dsh-cron/api/* (#54, ADR-0001).
|
|
3
|
+
*
|
|
4
|
+
* Everything a browser panel needs stays on the local, cross-origin-protected
|
|
5
|
+
* routes; this prefix exists for CI and host-side automation, so it is the only
|
|
6
|
+
* surface guarded by a bearer token. The token lives in the plugin settings as
|
|
7
|
+
* a masked value: an unconfigured token disables the surface (503) instead of
|
|
8
|
+
* leaving it open, and a wrong token is answered with 401.
|
|
9
|
+
*
|
|
10
|
+
* The operations themselves are shared with the panel routes (lib/api.js), so
|
|
11
|
+
* the confirmation gate for code-executing tasks and the config-owned refusals
|
|
12
|
+
* apply here exactly as they do over HTTP from the UI.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { sendJson } from './http-utils.js';
|
|
17
|
+
import { handleExternalTaskRequest } from './api.js';
|
|
18
|
+
|
|
19
|
+
/** Settings key that holds the bearer token. */
|
|
20
|
+
export const API_TOKEN_SETTING_KEY = 'apiToken';
|
|
21
|
+
|
|
22
|
+
/** Route prefix owned by this surface. */
|
|
23
|
+
export const EXTERNAL_API_PREFIX = '/dsh-cron/api';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Constant-time token comparison. Length is compared first because
|
|
27
|
+
* timingSafeEqual throws on buffers of different sizes; a length mismatch is
|
|
28
|
+
* not a secret worth hiding for a fixed-length token.
|
|
29
|
+
*/
|
|
30
|
+
export function tokenMatches(expected, provided) {
|
|
31
|
+
if (!expected || !provided) return false;
|
|
32
|
+
const a = Buffer.from(String(expected), 'utf8');
|
|
33
|
+
const b = Buffer.from(String(provided), 'utf8');
|
|
34
|
+
if (a.length !== b.length) return false;
|
|
35
|
+
return timingSafeEqual(a, b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bearer token of a request, or an empty string when there is none. */
|
|
39
|
+
export function readBearerToken(req) {
|
|
40
|
+
const header = String((req.headers && req.headers.authorization) || '');
|
|
41
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
42
|
+
return match ? match[1].trim() : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Guard the external prefix, then hand the request to the shared task
|
|
47
|
+
* operations. `getToken` is read per request so a settings change applies
|
|
48
|
+
* without a restart.
|
|
49
|
+
*/
|
|
50
|
+
export function createExternalApiHandler({ store, scheduler, getToken }) {
|
|
51
|
+
return async function handleExternalApi(req, res) {
|
|
52
|
+
const expected = typeof getToken === 'function' ? getToken() : '';
|
|
53
|
+
if (!expected) {
|
|
54
|
+
sendJson(res, 503, {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: 'External API is disabled: set the "' + API_TOKEN_SETTING_KEY + '" setting to enable it',
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!tokenMatches(expected, readBearerToken(req))) {
|
|
61
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
65
|
+
// `url` must be inside the try: a malformed body (an unparseable schedule,
|
|
66
|
+
// for example) surfaces as a thrown error from the shared handlers, and an
|
|
67
|
+
// unhandled rejection here would answer nothing at all.
|
|
68
|
+
try {
|
|
69
|
+
await handleExternalTaskRequest({ store, scheduler, req, res, url });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
sendJson(res, (err && err.statusCode) || 500, { ok: false, error: (err && err.message) || String(err) });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|