@goodandready/dsh-cron 0.2.6 → 0.2.8

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/index.js CHANGED
@@ -62,6 +62,9 @@ import { makeAsk } from './silent-rule.js';
62
62
  // a bare re-export creates no local binding and the plugin then fails to load
63
63
  // with "createCronApiHandler is not defined".
64
64
  import { createCronApiHandler } from './api.js';
65
+ import { createMetricsHandler } from './metrics.js';
66
+ import { applyConfigSync, isConfigOwned, configOwnedMessage } from './config-jobs.js';
67
+ import { createExternalApiHandler, EXTERNAL_API_PREFIX } from './external-api.js';
65
68
  export { createCronApiHandler };
66
69
 
67
70
  export const name = '@goodandready/dsh-cron';
@@ -97,6 +100,16 @@ export const Config = z.object({
97
100
  giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
98
101
  giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
99
102
  giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
103
+ // --- Declarative jobs (#50, ADR-0001) ---
104
+ // The array is intentionally permissive: each entry is validated on its own
105
+ // with a precise message (see lib/config-jobs.js), so one broken entry cannot
106
+ // reject the whole profile config. Declared jobs are owned by the config:
107
+ // they are created or updated at startup, and removed when they disappear
108
+ // from the file. Tasks created in the UI, over the API or by an agent tool
109
+ // are never touched.
110
+ jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
111
+ // --- External REST API (#54, ADR-0001) ---
112
+ apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)'),
100
113
  });
101
114
 
102
115
 
@@ -110,6 +123,7 @@ export const SETTINGS_SYNC_KEYS = [
110
123
  'pushplusUrl',
111
124
  'ttsBaseUrl',
112
125
  'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
126
+ 'apiToken',
113
127
  ];
114
128
 
115
129
  /**
@@ -327,6 +341,18 @@ export function apply(ctx, config) {
327
341
  }
328
342
  });
329
343
 
344
+ // #50: jobs declared in the profile config belong to the config. The sync
345
+ // runs before the scheduler starts and also when the section is gone: a
346
+ // config that dropped its jobs must still retire the tasks it used to own.
347
+ // A broken entry is reported and skipped, never fatal.
348
+ try {
349
+ const configJobs = Array.isArray(config && config.jobs) ? config.jobs : [];
350
+ const summary = applyConfigSync({ store, scheduler, entries: configJobs });
351
+ console.log('[dsh-cron] config jobs synced: ' + JSON.stringify(summary));
352
+ } catch (err) {
353
+ console.error('[dsh-cron] config job sync failed:', err.message);
354
+ }
355
+
330
356
  scheduler.start();
331
357
 
332
358
  // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
@@ -366,6 +392,27 @@ export function apply(ctx, config) {
366
392
  handler: createCronApiHandler(store, scheduler, null)
367
393
  }), 'dsh-cron: /action/:id/:action');
368
394
 
395
+ // GET /dsh-cron/metrics (#53) — Prometheus text exposition
396
+ ctx.effect(() => ctx.webServer.register({
397
+ kind: 'exact',
398
+ path: '/dsh-cron/metrics',
399
+ handler: createMetricsHandler({ store, scheduler })
400
+ }), 'dsh-cron: /metrics');
401
+
402
+ // External REST API under /dsh-cron/api/* (#54, ADR-0001): the only surface
403
+ // guarded by a bearer token, so CI and host automation can drive the
404
+ // scheduler without a browser session. The token is read per request, so a
405
+ // settings change applies without a restart.
406
+ ctx.effect(() => ctx.webServer.register({
407
+ kind: 'prefix',
408
+ path: EXTERNAL_API_PREFIX,
409
+ handler: createExternalApiHandler({
410
+ store,
411
+ scheduler,
412
+ getToken: () => store.getSettings().apiToken || ''
413
+ })
414
+ }), 'dsh-cron: /api[...]');
415
+
369
416
  // GET /dsh-cron/models
370
417
  ctx.effect(() => ctx.webServer.register({
371
418
  kind: 'exact',
@@ -595,6 +642,10 @@ export function apply(ctx, config) {
595
642
  render: (_args, val) => [{ type: 'text', text: val.message }]
596
643
  },
597
644
  execute: async (args) => {
645
+ // A config-owned task cannot be paused from here either: the next start
646
+ // would resume it, and the agent would have reported a change that never
647
+ // happened (#50 review finding).
648
+ if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
598
649
  const task = scheduler.pauseTask(args.id);
599
650
  if (!task) return { success: false, message: 'Task not found' };
600
651
  return { success: true, message: `Task "${task.title}" paused` };
@@ -619,6 +670,7 @@ export function apply(ctx, config) {
619
670
  render: (_args, val) => [{ type: 'text', text: val.message }]
620
671
  },
621
672
  execute: async (args) => {
673
+ if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
622
674
  const task = scheduler.resumeTask(args.id);
623
675
  if (!task) return { success: false, message: 'Task not found' };
624
676
  return { success: true, message: `Task "${task.title}" resumed (${task.scheduleText})` };
@@ -643,6 +695,9 @@ export function apply(ctx, config) {
643
695
  render: (_args, val) => [{ type: 'text', text: val.message }]
644
696
  },
645
697
  execute: async (args) => {
698
+ // Deleting a config-owned task would also drop its run history, and the
699
+ // task would come back at the next start without it.
700
+ if (isConfigOwned(store.get(args.id))) return { success: false, message: configOwnedMessage(args.id) };
646
701
  scheduler.pauseTask(args.id);
647
702
  const ok = store.delete(args.id);
648
703
  return { success: ok, message: ok ? 'Task deleted' : 'Task not found' };
package/lib/metrics.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Prometheus exposition of the scheduler (#53).
3
+ *
4
+ * The renderer is hand-rolled on purpose: the profile has no prom-client and
5
+ * adding a production dependency for a handful of counters is not justified.
6
+ * Only counts, statuses and durations are exported — never prompts, run output
7
+ * or task configuration, so the endpoint can be scraped without leaking work
8
+ * content.
9
+ */
10
+
11
+ export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
12
+
13
+ /** Run statuses the scheduler can finish with. */
14
+ export const RUN_STATUSES = ['success', 'error', 'timeout', 'skipped', 'missed'];
15
+
16
+ const NL = String.fromCharCode(10);
17
+ const BACKSLASH = String.fromCharCode(92);
18
+ const QUOTE = String.fromCharCode(34);
19
+
20
+ /** Escape a label value for the Prometheus text format. */
21
+ export function escapeLabel(value) {
22
+ return String(value)
23
+ .split(BACKSLASH).join(BACKSLASH + BACKSLASH)
24
+ .split(QUOTE).join(BACKSLASH + QUOTE)
25
+ .split(NL).join(BACKSLASH + 'n');
26
+ }
27
+
28
+ function sample(name, labels, value) {
29
+ const rendered = Object.keys(labels)
30
+ .map((key) => key + '=' + QUOTE + escapeLabel(labels[key]) + QUOTE)
31
+ .join(',');
32
+ return name + (rendered ? '{' + rendered + '}' : '') + ' ' + value;
33
+ }
34
+
35
+ /**
36
+ * Render the current scheduler state as Prometheus text exposition.
37
+ * Pure function over plain data so it is testable without a store.
38
+ */
39
+ export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {}) {
40
+ const lines = [];
41
+ const byStatus = {};
42
+ for (const task of tasks) {
43
+ const status = task.status || 'unknown';
44
+ byStatus[status] = (byStatus[status] || 0) + 1;
45
+ }
46
+
47
+ lines.push('# HELP dsh_cron_tasks_total Scheduled tasks by status.');
48
+ // A gauge despite the name: this is a snapshot of how many tasks are in each
49
+ // state, not a monotonically growing total.
50
+ lines.push('# TYPE dsh_cron_tasks_total gauge');
51
+ for (const status of Object.keys(byStatus).sort()) {
52
+ lines.push(sample('dsh_cron_tasks_total', { status }, byStatus[status]));
53
+ }
54
+
55
+ lines.push('# HELP dsh_cron_task_last_duration_seconds Duration of the last finished run of a task.');
56
+ lines.push('# TYPE dsh_cron_task_last_duration_seconds gauge');
57
+ for (const task of tasks) {
58
+ // lastRunAt marks a finished run: a task that never ran still carries
59
+ // lastDurationMs = 0, and exporting that would show instant runs in
60
+ // monitoring.
61
+ if (!Number.isFinite(task.lastDurationMs) || !task.lastRunAt) continue;
62
+ lines.push(sample('dsh_cron_task_last_duration_seconds', { task: task.id }, task.lastDurationMs / 1000));
63
+ }
64
+
65
+ lines.push('# HELP dsh_cron_runs_total Finished runs since the plugin started, by status.');
66
+ lines.push('# TYPE dsh_cron_runs_total counter');
67
+ for (const status of RUN_STATUSES) {
68
+ lines.push(sample('dsh_cron_runs_total', { status }, Number(runCounters[status]) || 0));
69
+ }
70
+
71
+ lines.push('# HELP dsh_cron_run_records Run records currently kept in memory.');
72
+ lines.push('# TYPE dsh_cron_run_records gauge');
73
+ lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
74
+
75
+ return lines.join(NL) + NL;
76
+ }
77
+
78
+ /**
79
+ * GET /dsh-cron/metrics — read-only. A scraper is a plain local client, so no
80
+ * cross-origin check applies, and nothing sensitive is exposed.
81
+ */
82
+ export function createMetricsHandler({ store, scheduler }) {
83
+ return function handleMetrics(req, res) {
84
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
85
+ res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
86
+ res.end('Method not allowed' + NL);
87
+ return;
88
+ }
89
+ const body = renderMetrics({
90
+ tasks: store.list({ status: 'all' }),
91
+ stats: store.getAggregatedStats(),
92
+ runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
93
+ });
94
+ res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
95
+ res.end(req.method === 'HEAD' ? undefined : body);
96
+ };
97
+ }
package/lib/scheduler.js CHANGED
@@ -36,82 +36,78 @@ export function describeCron(cronPattern) {
36
36
  return cronPattern;
37
37
  }
38
38
 
39
- export function parseScheduleExpression(input) {
40
- const str = String(input || '').trim();
41
- if (!str) throw new Error('Schedule expression must not be empty');
42
-
43
- // 1. One-shot ISO 8601 timestamp or explicit "at: <ISO/datetime>"
39
+ /** One-shot from an ISO timestamp or an explicit "at:" prefix, else null. */
40
+ function parseAtExpression(str) {
44
41
  const atPrefixMatch = str.match(/^(?:at:\s*|at\s+)(.+)$/i);
45
42
  const candidateIso = atPrefixMatch ? atPrefixMatch[1].trim() : str;
46
43
  const parsedDate = new Date(candidateIso);
47
- if (!isNaN(parsedDate.getTime()) && (candidateIso.includes('-') || candidateIso.includes('T') || atPrefixMatch)) {
48
- const targetMs = parsedDate.getTime();
49
- return {
50
- cronPattern: null,
51
- isOneShot: true,
52
- targetTimestamp: targetMs,
53
- humanText: `One-shot at ${parsedDate.toISOString()}`,
54
- nextRun: targetMs
55
- };
44
+ if (isNaN(parsedDate.getTime()) || !(candidateIso.includes('-') || candidateIso.includes('T') || atPrefixMatch)) {
45
+ return null;
56
46
  }
47
+ const targetMs = parsedDate.getTime();
48
+ return {
49
+ cronPattern: null,
50
+ isOneShot: true,
51
+ targetTimestamp: targetMs,
52
+ humanText: `One-shot at ${parsedDate.toISOString()}`,
53
+ nextRun: targetMs
54
+ };
55
+ }
57
56
 
58
- // 2. Relative one-shot: "in 20m", "in 2h", "in 30s", "in 1d", "через 15 минут"
59
- // Russian keywords are accepted input aliases, not display strings.
60
- const inRelMatch = str.match(/^(?:in\s+|через\s+)(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|мин|минут|часа?|часов)?$/i);
61
- if (inRelMatch) {
62
- const num = parseInt(inRelMatch[1], 10);
63
- const unit = (inRelMatch[2] || 'm').toLowerCase();
64
- let delayMs = num * 60 * 1000;
65
- let unitText = `${num} min`;
66
- if (unit.startsWith('s')) {
67
- delayMs = num * 1000;
68
- unitText = `${num} sec`;
69
- } else if (unit.startsWith('h') || unit.startsWith('час')) {
70
- delayMs = num * 3600 * 1000;
71
- unitText = `${num} h`;
72
- } else if (unit.startsWith('d')) {
73
- delayMs = num * 86400 * 1000;
74
- unitText = `${num} d`;
75
- }
76
- const targetMs = Date.now() + delayMs;
77
- return {
78
- cronPattern: null,
79
- isOneShot: true,
80
- targetTimestamp: targetMs,
81
- humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
82
- nextRun: targetMs
83
- };
84
- }
57
+ /**
58
+ * Relative one-shot: "in 20m", "in 2h", "in 30s", "in 1d" and the accepted
59
+ * Russian input aliases ("через 15 минут").
60
+ */
61
+ function parseRelativeOneShot(str) {
62
+ const match = str.match(/^(?:in\s+|через\s+)(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|мин|минут|часа?|часов)?$/i);
63
+ if (!match) return null;
64
+ const num = parseInt(match[1], 10);
65
+ const unit = (match[2] || 'm').toLowerCase();
66
+ let delayMs = num * 60 * 1000;
67
+ let unitText = `${num} min`;
68
+ if (unit.startsWith('s')) {
69
+ delayMs = num * 1000;
70
+ unitText = `${num} sec`;
71
+ } else if (unit.startsWith('h') || unit.startsWith('час')) {
72
+ delayMs = num * 3600 * 1000;
73
+ unitText = `${num} h`;
74
+ } else if (unit.startsWith('d')) {
75
+ delayMs = num * 86400 * 1000;
76
+ unitText = `${num} d`;
77
+ }
78
+ const targetMs = Date.now() + delayMs;
79
+ return {
80
+ cronPattern: null,
81
+ isOneShot: true,
82
+ targetTimestamp: targetMs,
83
+ humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
84
+ nextRun: targetMs
85
+ };
86
+ }
85
87
 
86
- // 3. Repeating Interval: "every 5m", "every 2h", "every 1d"
87
- const intervalMatch = str.match(/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i);
88
- if (intervalMatch) {
89
- const num = parseInt(intervalMatch[1], 10);
90
- const unit = (intervalMatch[2] || 'm').toLowerCase();
91
- if (unit.startsWith('m')) {
92
- return {
93
- cronPattern: `*/${num} * * * *`,
94
- humanText: `Every ${num} minutes`,
95
- isInterval: true,
96
- };
97
- }
98
- if (unit.startsWith('h')) {
99
- return {
100
- cronPattern: `0 */${num} * * *`,
101
- humanText: `Every ${num} hours`,
102
- isInterval: true,
103
- };
104
- }
105
- if (unit.startsWith('d')) {
106
- return {
107
- cronPattern: `0 0 */${num} * *`,
108
- humanText: `Every ${num} days`,
109
- isInterval: true,
110
- };
111
- }
88
+ /** Repeating interval: "every 5m", "every 2h", "every 1d". */
89
+ function parseIntervalExpression(str) {
90
+ const match = str.match(/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i);
91
+ if (!match) return null;
92
+ const num = parseInt(match[1], 10);
93
+ const unit = (match[2] || 'm').toLowerCase();
94
+ if (unit.startsWith('m')) {
95
+ return { cronPattern: `*/${num} * * * *`, humanText: `Every ${num} minutes`, isInterval: true };
112
96
  }
97
+ if (unit.startsWith('h')) {
98
+ return { cronPattern: `0 */${num} * * *`, humanText: `Every ${num} hours`, isInterval: true };
99
+ }
100
+ if (unit.startsWith('d')) {
101
+ return { cronPattern: `0 0 */${num} * *`, humanText: `Every ${num} days`, isInterval: true };
102
+ }
103
+ return null;
104
+ }
113
105
 
114
- // 4. Standard @-shorthands (#15)
106
+ /**
107
+ * @-shorthands (#15) and the friendly aliases, including the Russian input
108
+ * aliases. Russian keywords are accepted input, not display strings.
109
+ */
110
+ function parseAliasExpression(str) {
115
111
  const lower = str.toLowerCase();
116
112
  if (lower === '@hourly') return { cronPattern: '0 * * * *', humanText: 'Every hour' };
117
113
  if (lower === '@daily' || lower === '@midnight') return { cronPattern: '0 0 * * *', humanText: 'Every day at 00:00' };
@@ -120,10 +116,9 @@ export function parseScheduleExpression(input) {
120
116
  if (lower === '@yearly' || lower === '@annually') return { cronPattern: '0 0 1 1 *', humanText: 'Every year on Jan 1 at 00:00' };
121
117
  const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
122
118
  if (everyAlias) {
123
- return parseScheduleExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
119
+ // "@every N unit" is another spelling of the interval branch.
120
+ return parseIntervalExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
124
121
  }
125
-
126
- // 5. Friendly recurring aliases (English plus accepted Russian input aliases)
127
122
  if (lower === 'daily' || lower === 'каждый день') {
128
123
  return { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
129
124
  }
@@ -133,10 +128,16 @@ export function parseScheduleExpression(input) {
133
128
  if (lower === 'hourly' || lower === 'каждый час') {
134
129
  return { cronPattern: '0 * * * *', humanText: 'Every hour' };
135
130
  }
131
+ return null;
132
+ }
136
133
 
137
- // 5. Standard 5-field cron expression
134
+ /** Validate a 5-field cron expression; the only branch that throws. */
135
+ function parseCronExpression(str) {
138
136
  try {
139
- const testJob = new Cron(str);
137
+ // #117: croner 10 made numeric-prefix steps (0/10, 30/30) a parse error,
138
+ // but stored schedules created under croner 9 use them. sloppyRanges
139
+ // restores the croner 9 range behaviour so existing tasks keep firing.
140
+ const testJob = new Cron(str, { sloppyRanges: true });
140
141
  const next = testJob.nextRun();
141
142
  return {
142
143
  cronPattern: str,
@@ -148,6 +149,22 @@ export function parseScheduleExpression(input) {
148
149
  }
149
150
  }
150
151
 
152
+ /**
153
+ * Turn a typed schedule into a cron pattern or a one-shot target. The branch
154
+ * order is the contract: a timestamp, a relative one-shot, an interval and an
155
+ * alias are all more specific than a raw cron expression.
156
+ */
157
+ export function parseScheduleExpression(input) {
158
+ const str = String(input || '').trim();
159
+ if (!str) throw new Error('Schedule expression must not be empty');
160
+
161
+ return parseAtExpression(str)
162
+ || parseRelativeOneShot(str)
163
+ || parseIntervalExpression(str)
164
+ || parseAliasExpression(str)
165
+ || parseCronExpression(str);
166
+ }
167
+
151
168
  export class TaskScheduler {
152
169
  constructor(store, executeFn, options = {}) {
153
170
  this.store = store;
@@ -160,6 +177,7 @@ export class TaskScheduler {
160
177
  this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
161
178
  this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
162
179
  this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
180
+ this.runCounters = {}; // status -> finished runs since start (#53, /dsh-cron/metrics)
163
181
  }
164
182
 
165
183
  isRunning(taskId) {
@@ -194,6 +212,7 @@ export class TaskScheduler {
194
212
  const missedAt = task.nextRunAt;
195
213
  const policy = task.misfirePolicy || 'skip'; // #12: skip | runOnce | catchUpAll
196
214
  console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()} (misfire: ${policy})`);
215
+ this.countRun('missed');
197
216
  this.store.recordRun(task.id, {
198
217
  at: missedAt,
199
218
  status: 'missed',
@@ -253,59 +272,66 @@ export class TaskScheduler {
253
272
  this.running.clear();
254
273
  }
255
274
 
256
- scheduleTask(task) {
257
- // Clear any existing job or timer for this task
258
- if (this.jobs.has(task.id)) {
259
- this.jobs.get(task.id).stop();
260
- this.jobs.delete(task.id);
275
+ /** Drop an armed job or one-shot timer before re-scheduling a task. */
276
+ clearScheduled(taskId) {
277
+ if (this.jobs.has(taskId)) {
278
+ this.jobs.get(taskId).stop();
279
+ this.jobs.delete(taskId);
280
+ }
281
+ if (this.timers.has(taskId)) {
282
+ clearTimeout(this.timers.get(taskId));
283
+ this.timers.delete(taskId);
261
284
  }
262
- if (this.timers.has(task.id)) {
263
- clearTimeout(this.timers.get(task.id));
285
+ }
286
+
287
+ /** Arm a one-shot task; a target already in the past fires immediately. */
288
+ scheduleOneShot(task, parsed) {
289
+ task.oneShot = true;
290
+ task.nextRunAt = parsed.targetTimestamp;
291
+ this.store.set(task);
292
+
293
+ const delay = Math.max(0, parsed.targetTimestamp - Date.now());
294
+ const timer = setTimeout(() => {
264
295
  this.timers.delete(task.id);
296
+ this.runTask(task.id).catch((err) => {
297
+ console.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
298
+ });
299
+ }, delay);
300
+ this.timers.set(task.id, timer);
301
+ }
302
+
303
+ /** Arm a recurring cron job and persist the next run timestamp. */
304
+ scheduleCron(task, parsed) {
305
+ const timezone = task.timezone || this.defaultTimezone || undefined;
306
+ const job = new Cron(parsed.cronPattern, {
307
+ protect: true,
308
+ sloppyRanges: true, // #117: keep croner 9 range syntax working
309
+ catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
310
+ ...(timezone ? { timezone } : {}),
311
+ }, async () => {
312
+ await this.runTask(task.id);
313
+ });
314
+
315
+ this.jobs.set(task.id, job);
316
+ const next = job.nextRun();
317
+ if (next) {
318
+ task.nextRunAt = next.getTime();
319
+ this.store.set(task);
265
320
  }
321
+ }
266
322
 
323
+ scheduleTask(task) {
324
+ this.clearScheduled(task.id);
267
325
  if (task.status !== 'active') return;
268
326
  this.clearRetryTimer(task.id);
269
327
 
270
328
  try {
271
329
  const parsed = parseScheduleExpression(task.schedule);
272
- const timezone = task.timezone || this.defaultTimezone || undefined;
273
-
274
- // Handle One-shot task
275
330
  if (parsed.isOneShot) {
276
- task.oneShot = true;
277
- const now = Date.now();
278
- const delay = Math.max(0, parsed.targetTimestamp - now);
279
- task.nextRunAt = parsed.targetTimestamp;
280
- this.store.set(task);
281
-
282
- // If target is in the past, or when delay triggers:
283
- const timer = setTimeout(() => {
284
- this.timers.delete(task.id);
285
- this.runTask(task.id).catch((err) => {
286
- console.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
287
- });
288
- }, delay);
289
-
290
- this.timers.set(task.id, timer);
331
+ this.scheduleOneShot(task, parsed);
291
332
  return;
292
333
  }
293
-
294
- // Handle Recurring Cron job
295
- const job = new Cron(parsed.cronPattern, {
296
- protect: true,
297
- catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
298
- ...(timezone ? { timezone } : {}),
299
- }, async () => {
300
- await this.runTask(task.id);
301
- });
302
-
303
- this.jobs.set(task.id, job);
304
- const next = job.nextRun();
305
- if (next) {
306
- task.nextRunAt = next.getTime();
307
- this.store.set(task);
308
- }
334
+ this.scheduleCron(task, parsed);
309
335
  } catch (err) {
310
336
  console.error(`[dsh-cron] failed to schedule task ${task.id}:`, err.message);
311
337
  }
@@ -351,8 +377,20 @@ export class TaskScheduler {
351
377
  return currentRun;
352
378
  }
353
379
 
380
+ /** Count a finished run for the metrics endpoint (#53). */
381
+ countRun(status) {
382
+ const key = status || 'unknown';
383
+ this.runCounters[key] = (this.runCounters[key] || 0) + 1;
384
+ }
385
+
386
+ /** Snapshot of the run counters; the caller must not mutate it. */
387
+ getRunCounters() {
388
+ return { ...this.runCounters };
389
+ }
390
+
354
391
  /** Record a run that never started, with the reason in the history entry. */
355
392
  recordSkipped(taskId, reason) {
393
+ this.countRun('skipped');
356
394
  this.store.recordRun(taskId, {
357
395
  at: Date.now(),
358
396
  status: 'skipped',
@@ -595,6 +633,7 @@ export class TaskScheduler {
595
633
  * cannot reject the cron callback.
596
634
  */
597
635
  finishRun(task, taskId, status, queuedCount) {
636
+ this.countRun(status);
598
637
  try {
599
638
  if (task.oneShot) {
600
639
  task.status = 'completed';
package/lib/store.js CHANGED
@@ -8,8 +8,8 @@ import { getDshDefaultTelegramCredentials } from './telegram.js';
8
8
  * DSH_DATA_DIR wins when the harness sets it. Otherwise the profile home
9
9
  * (DSH_HOME) is authoritative: falling back to ~/.dsh would make an isolated
10
10
  * profile — a test contour, a second profile — write its tasks into another
11
- * home's data directory, which is how the MiniPC test cycle leaked a task into
12
- * the non-test profile.
11
+ * home's data directory, which is how an isolated test cycle once leaked a
12
+ * task into the wrong profile.
13
13
  */
14
14
  export function getDefaultStorePath() {
15
15
  const base = process.env.DSH_DATA_DIR
@@ -139,7 +139,7 @@ export class TaskStore {
139
139
  * fields (botTokenRef, ntfyTokenRef, …) take precedence over them.
140
140
  */
141
141
  static get MASKED_SETTING_KEYS() {
142
- return ['botToken', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey'];
142
+ return ['botToken', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey', 'apiToken'];
143
143
  }
144
144
 
145
145
  saveSettings(newSettings = {}) {
package/lib/task-patch.js CHANGED
@@ -12,6 +12,8 @@ import { parseScheduleExpression } from './scheduler.js';
12
12
  import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
13
13
  import { PATCHABLE_TASK_FIELDS, validateTaskType } from './task-transfer.js';
14
14
  import { pickPatchableFields } from './http-utils.js';
15
+ import { unknownChannelIds } from './channels.js';
16
+ import { isConfigOwned, configOwnedMessage } from './config-jobs.js';
15
17
 
16
18
  /** True when a patch changes a task into a type that executes code. */
17
19
  export function isCodeTypeSwitch(current, nextType) {
@@ -32,6 +34,15 @@ export function buildTaskPatch(current, body) {
32
34
  const typeError = validateTaskType(patch.type, { ...current, ...patch });
33
35
  if (typeError) return { ok: false, error: typeError };
34
36
  }
37
+ if (patch.channels !== undefined) {
38
+ // #121: a typo in a channel id used to be dropped silently, so the client
39
+ // got ok: true and a task that never notified anyone. The refusal lives
40
+ // here so the HTTP route and the tool behave identically.
41
+ const unknown = unknownChannelIds(patch.channels);
42
+ if (unknown.length) {
43
+ return { ok: false, error: 'Unknown channel ids: ' + unknown.join(', '), unknownChannels: unknown };
44
+ }
45
+ }
35
46
  if (patch.schedule !== undefined) {
36
47
  // An empty schedule would leave an armed task that can never fire.
37
48
  if (!String(patch.schedule).trim()) return { ok: false, error: 'Schedule cannot be empty' };
@@ -58,6 +69,12 @@ export function buildTaskPatch(current, body) {
58
69
  export function applyTaskPatch({ store, scheduler, id, body, allowCodeSwitch = false }) {
59
70
  const current = store.get(id);
60
71
  if (!current) return { ok: false, notFound: true, error: 'Task not found' };
72
+ // A task declared in the profile config (#50) is owned by that file: any
73
+ // change made here disappears at the next start, so the refusal lives with
74
+ // the patch logic and covers both the HTTP route and the agent tool.
75
+ if (isConfigOwned(current)) {
76
+ return { ok: false, configOwned: true, error: configOwnedMessage(id) };
77
+ }
61
78
  if (isCodeTypeSwitch(current, body && body.type) && allowCodeSwitch !== true) {
62
79
  return {
63
80
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -14,7 +14,8 @@
14
14
  "lib/",
15
15
  "cordis.patch.yml",
16
16
  "README.md",
17
- "docs/",
17
+ "docs/README.ru.md",
18
+ "docs/README.zh.md",
18
19
  "LICENSE"
19
20
  ],
20
21
  "scripts": {
@@ -47,7 +48,7 @@
47
48
  }
48
49
  },
49
50
  "dependencies": {
50
- "croner": "^9.1.0"
51
+ "croner": "^10.0.1"
51
52
  },
52
53
  "peerDependencies": {
53
54
  "@deepseek-ai/cordis": "^4.0.1",