@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/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 +153 -114
- package/lib/store.js +3 -3
- package/lib/task-patch.js +17 -0
- package/package.json +4 -3
- 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/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
|
-
|
|
40
|
-
|
|
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 (
|
|
48
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
/** Validate a 5-field cron expression; the only branch that throws. */
|
|
135
|
+
function parseCronExpression(str) {
|
|
138
136
|
try {
|
|
139
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
if (this.jobs.has(
|
|
259
|
-
this.jobs.get(
|
|
260
|
-
this.jobs.delete(
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
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
|
|
12
|
-
* the
|
|
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.
|
|
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": "^
|
|
51
|
+
"croner": "^10.0.1"
|
|
51
52
|
},
|
|
52
53
|
"peerDependencies": {
|
|
53
54
|
"@deepseek-ai/cordis": "^4.0.1",
|