@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/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, { ok: true, imported: applied.imported.length, summary, tasks: applied.imported });
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
- if (!store.get(id)) {
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, { ok: false, error });
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
- if (!Array.isArray(task.channels)) return [];
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
- onClick: () => openEditModal(task),
1741
- onKeyDown: (e) => {
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
+ }