@stonyx/cron 0.2.1-beta.24 → 0.2.1-beta.25
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/package.json +8 -1
- package/src/cron-parser.js +246 -0
- package/src/job.js +200 -0
- package/src/locked.js +34 -0
- package/src/normalize.js +163 -0
- package/src/run-log.js +79 -0
- package/src/schedule.js +81 -0
- package/src/service.js +303 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"keywords": [
|
|
4
4
|
"stonyx-module"
|
|
5
5
|
],
|
|
6
|
-
"version": "0.2.1-beta.
|
|
6
|
+
"version": "0.2.1-beta.25",
|
|
7
7
|
"description": "Cron/job scheduler for Stonyx framework",
|
|
8
8
|
"main": "src/main.js",
|
|
9
9
|
"type": "module",
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
],
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/main.js",
|
|
15
|
+
"./service": "./src/service.js",
|
|
16
|
+
"./cron-parser": "./src/cron-parser.js",
|
|
17
|
+
"./schedule": "./src/schedule.js",
|
|
18
|
+
"./job": "./src/job.js",
|
|
19
|
+
"./normalize": "./src/normalize.js",
|
|
20
|
+
"./locked": "./src/locked.js",
|
|
21
|
+
"./run-log": "./src/run-log.js",
|
|
15
22
|
"./min-heap": "./src/min-heap.js"
|
|
16
23
|
},
|
|
17
24
|
"publishConfig": {
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 5-field cron expression parser with next-occurrence computation.
|
|
3
|
+
* No external dependencies — built for stonyx-cron.
|
|
4
|
+
*
|
|
5
|
+
* Fields: minute(0-59) hour(0-23) day-of-month(1-31) month(1-12) day-of-week(0-6)
|
|
6
|
+
* Supports: wildcards(*), ranges(1-5), steps(* /5), lists(1,3,5), names(jan-dec, sun-sat)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MONTH_NAMES = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
|
|
10
|
+
const DAY_NAMES = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
11
|
+
|
|
12
|
+
const FIELD_RANGES = [
|
|
13
|
+
{ min: 0, max: 59 }, // minute
|
|
14
|
+
{ min: 0, max: 23 }, // hour
|
|
15
|
+
{ min: 1, max: 31 }, // day of month
|
|
16
|
+
{ min: 1, max: 12 }, // month
|
|
17
|
+
{ min: 0, max: 6 }, // day of week
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a single cron field into a sorted array of allowed values.
|
|
22
|
+
* @param {string} field - The field string (e.g., "1-5", "* /15", "mon,wed,fri")
|
|
23
|
+
* @param {number} fieldIndex - Index (0=minute, 1=hour, 2=dom, 3=month, 4=dow)
|
|
24
|
+
* @returns {number[]} Sorted array of allowed integer values
|
|
25
|
+
*/
|
|
26
|
+
export function parseField(field, fieldIndex) {
|
|
27
|
+
const { min, max } = FIELD_RANGES[fieldIndex];
|
|
28
|
+
const names = fieldIndex === 3 ? MONTH_NAMES : fieldIndex === 4 ? DAY_NAMES : null;
|
|
29
|
+
|
|
30
|
+
const resolveToken = (token) => {
|
|
31
|
+
if (names) {
|
|
32
|
+
const lower = token.toLowerCase();
|
|
33
|
+
if (lower in names) return names[lower];
|
|
34
|
+
}
|
|
35
|
+
const n = Number(token);
|
|
36
|
+
if (!Number.isInteger(n)) throw new Error(`Invalid cron value: "${token}" in field ${fieldIndex}`);
|
|
37
|
+
// Normalize day-of-week 7 → 0 (both mean Sunday)
|
|
38
|
+
if (fieldIndex === 4 && n === 7) return 0;
|
|
39
|
+
return n;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const results = new Set();
|
|
43
|
+
|
|
44
|
+
for (const part of field.split(',')) {
|
|
45
|
+
const trimmed = part.trim();
|
|
46
|
+
const [rangeStr, stepStr] = trimmed.split('/');
|
|
47
|
+
const step = stepStr !== undefined ? Number(stepStr) : 1;
|
|
48
|
+
|
|
49
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
50
|
+
throw new Error(`Invalid step "${stepStr}" in cron field ${fieldIndex}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let start, end;
|
|
54
|
+
|
|
55
|
+
if (rangeStr === '*') {
|
|
56
|
+
start = min;
|
|
57
|
+
end = max;
|
|
58
|
+
} else if (rangeStr.includes('-')) {
|
|
59
|
+
const [lo, hi] = rangeStr.split('-');
|
|
60
|
+
start = resolveToken(lo);
|
|
61
|
+
end = resolveToken(hi);
|
|
62
|
+
} else {
|
|
63
|
+
start = resolveToken(rangeStr);
|
|
64
|
+
end = stepStr !== undefined ? max : start;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (start < min || start > max || end < min || end > max) {
|
|
68
|
+
throw new Error(`Value out of range [${min}-${max}] in cron field ${fieldIndex}: "${trimmed}"`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (let v = start; v <= end; v += step) {
|
|
72
|
+
results.add(v);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...results].sort((a, b) => a - b);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a 5-field cron expression into field arrays.
|
|
81
|
+
* @param {string} expr - Cron expression (e.g., "0 9 * * 1-5")
|
|
82
|
+
* @returns {{ minutes: number[], hours: number[], daysOfMonth: number[], months: number[], daysOfWeek: number[] }}
|
|
83
|
+
*/
|
|
84
|
+
export function parseCronExpression(expr) {
|
|
85
|
+
const fields = expr.trim().split(/\s+/);
|
|
86
|
+
if (fields.length !== 5) {
|
|
87
|
+
throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
minutes: parseField(fields[0], 0),
|
|
92
|
+
hours: parseField(fields[1], 1),
|
|
93
|
+
daysOfMonth: parseField(fields[2], 2),
|
|
94
|
+
months: parseField(fields[3], 3),
|
|
95
|
+
daysOfWeek: parseField(fields[4], 4),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the number of days in a given month/year.
|
|
101
|
+
*/
|
|
102
|
+
function daysInMonth(year, month) {
|
|
103
|
+
return new Date(year, month, 0).getDate();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if a day-of-month + day-of-week pair matches the parsed expression.
|
|
108
|
+
*
|
|
109
|
+
* Standard cron behavior: if BOTH dom and dow are restricted (not *),
|
|
110
|
+
* then EITHER matching is sufficient (OR logic).
|
|
111
|
+
* If only one is restricted, it acts as the sole filter.
|
|
112
|
+
*/
|
|
113
|
+
function dayMatches(parsed, domWild, dowWild, dayOfMonth, dayOfWeek) {
|
|
114
|
+
const domMatch = parsed.daysOfMonth.includes(dayOfMonth);
|
|
115
|
+
const dowMatch = parsed.daysOfWeek.includes(dayOfWeek);
|
|
116
|
+
|
|
117
|
+
if (domWild && dowWild) return true;
|
|
118
|
+
if (domWild) return dowMatch;
|
|
119
|
+
if (dowWild) return domMatch;
|
|
120
|
+
return domMatch || dowMatch; // Both restricted → OR
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compute the next occurrence of a cron expression after a given timestamp.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} expr - 5-field cron expression
|
|
127
|
+
* @param {number} afterMs - Timestamp in milliseconds (exclusive — finds strictly after this)
|
|
128
|
+
* @param {string} [tz] - IANA timezone (defaults to system timezone)
|
|
129
|
+
* @returns {number|undefined} Next occurrence in milliseconds, or undefined if none within 4 years
|
|
130
|
+
*/
|
|
131
|
+
export function nextOccurrence(expr, afterMs, tz) {
|
|
132
|
+
const parsed = parseCronExpression(expr);
|
|
133
|
+
const exprFields = expr.trim().split(/\s+/);
|
|
134
|
+
const domWild = exprFields[2] === '*';
|
|
135
|
+
const dowWild = exprFields[4] === '*';
|
|
136
|
+
|
|
137
|
+
// Start from the next whole minute after afterMs
|
|
138
|
+
const startDate = new Date(afterMs);
|
|
139
|
+
startDate.setSeconds(0, 0);
|
|
140
|
+
startDate.setMinutes(startDate.getMinutes() + 1);
|
|
141
|
+
|
|
142
|
+
// Convert to target timezone for field matching
|
|
143
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
144
|
+
timeZone: tz || undefined,
|
|
145
|
+
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
146
|
+
hour: 'numeric', minute: 'numeric', hour12: false,
|
|
147
|
+
weekday: 'short',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
151
|
+
|
|
152
|
+
// Parse formatted date parts in the target timezone
|
|
153
|
+
function getLocalParts(date) {
|
|
154
|
+
const parts = {};
|
|
155
|
+
for (const { type, value } of formatter.formatToParts(date)) {
|
|
156
|
+
parts[type] = value;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
year: Number(parts.year),
|
|
160
|
+
month: Number(parts.month),
|
|
161
|
+
day: Number(parts.day),
|
|
162
|
+
hour: Number(parts.hour === '24' ? 0 : parts.hour),
|
|
163
|
+
minute: Number(parts.minute),
|
|
164
|
+
weekday: dayMap[parts.weekday] ?? 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Search limit: 4 years of minutes (≈ 2.1M iterations max)
|
|
169
|
+
const maxMs = afterMs + 4 * 365.25 * 24 * 60 * 60 * 1000;
|
|
170
|
+
let candidate = new Date(startDate);
|
|
171
|
+
|
|
172
|
+
while (candidate.getTime() <= maxMs) {
|
|
173
|
+
const p = getLocalParts(candidate);
|
|
174
|
+
|
|
175
|
+
// Check month
|
|
176
|
+
if (!parsed.months.includes(p.month)) {
|
|
177
|
+
// Advance to next matching month
|
|
178
|
+
const nextMonth = parsed.months.find(m => m > p.month);
|
|
179
|
+
if (nextMonth) {
|
|
180
|
+
// Stay in same year, advance to first day of nextMonth
|
|
181
|
+
candidate = advanceToMonth(candidate, p.year, nextMonth, tz, formatter, dayMap);
|
|
182
|
+
} else {
|
|
183
|
+
// Wrap to next year, first matching month
|
|
184
|
+
candidate = advanceToMonth(candidate, p.year + 1, parsed.months[0], tz, formatter, dayMap);
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check day (dom + dow)
|
|
190
|
+
if (!dayMatches(parsed, domWild, dowWild, p.day, p.weekday)) {
|
|
191
|
+
candidate.setMinutes(candidate.getMinutes() + (24 * 60 - p.hour * 60 - p.minute));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check hour
|
|
196
|
+
if (!parsed.hours.includes(p.hour)) {
|
|
197
|
+
const nextHour = parsed.hours.find(h => h > p.hour);
|
|
198
|
+
if (nextHour) {
|
|
199
|
+
candidate.setMinutes(candidate.getMinutes() + ((nextHour - p.hour) * 60 - p.minute));
|
|
200
|
+
} else {
|
|
201
|
+
// Advance to next day
|
|
202
|
+
candidate.setMinutes(candidate.getMinutes() + ((24 - p.hour) * 60 - p.minute));
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check minute
|
|
208
|
+
if (!parsed.minutes.includes(p.minute)) {
|
|
209
|
+
const nextMin = parsed.minutes.find(m => m > p.minute);
|
|
210
|
+
if (nextMin) {
|
|
211
|
+
candidate.setMinutes(candidate.getMinutes() + (nextMin - p.minute));
|
|
212
|
+
} else {
|
|
213
|
+
// Advance to next hour
|
|
214
|
+
candidate.setMinutes(candidate.getMinutes() + (60 - p.minute));
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// All fields match
|
|
220
|
+
return candidate.getTime();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create a Date advanced to the start of a specific month in a specific year,
|
|
228
|
+
* using the target timezone's midnight.
|
|
229
|
+
*/
|
|
230
|
+
function advanceToMonth(current, year, month, tz, formatter, dayMap) {
|
|
231
|
+
// Create a new date at ~start of the target month in UTC, then adjust
|
|
232
|
+
const d = new Date(current);
|
|
233
|
+
// Jump to approximately the right time
|
|
234
|
+
d.setFullYear(year, month - 1, 1);
|
|
235
|
+
d.setHours(0, 0, 0, 0);
|
|
236
|
+
return d;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate a cron expression without computing next occurrence.
|
|
241
|
+
* @param {string} expr - 5-field cron expression
|
|
242
|
+
* @throws {Error} if the expression is invalid
|
|
243
|
+
*/
|
|
244
|
+
export function validateCronExpression(expr) {
|
|
245
|
+
parseCronExpression(expr);
|
|
246
|
+
}
|
package/src/job.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job data model and state machine for the advanced scheduling system.
|
|
3
|
+
*/
|
|
4
|
+
import { computeNextRunAtMs, validateSchedule } from './schedule.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Error backoff table (milliseconds).
|
|
8
|
+
* Applied after consecutive errors to prevent hammering.
|
|
9
|
+
*/
|
|
10
|
+
const ERROR_BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
|
|
11
|
+
|
|
12
|
+
export function errorBackoffMs(consecutiveErrors) {
|
|
13
|
+
if (consecutiveErrors < 1) return 0;
|
|
14
|
+
return ERROR_BACKOFF_MS[Math.min(consecutiveErrors - 1, ERROR_BACKOFF_MS.length - 1)];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a new job object from input.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} input - Job creation input
|
|
21
|
+
* @param {string} input.name - Job name
|
|
22
|
+
* @param {object} input.schedule - Schedule definition (at/every/cron)
|
|
23
|
+
* @param {object} input.payload - What to execute
|
|
24
|
+
* @param {string} [input.description]
|
|
25
|
+
* @param {boolean} [input.enabled=true]
|
|
26
|
+
* @param {boolean} [input.deleteAfterRun=false]
|
|
27
|
+
* @param {string} [input.sessionTarget="isolated"]
|
|
28
|
+
* @param {string} [input.wakeMode="now"]
|
|
29
|
+
* @param {object} [input.delivery]
|
|
30
|
+
* @returns {object} Complete job object with state
|
|
31
|
+
*/
|
|
32
|
+
export function createJob(input) {
|
|
33
|
+
validateSchedule(input.schedule);
|
|
34
|
+
|
|
35
|
+
const nowMs = Date.now();
|
|
36
|
+
const enabled = input.enabled !== false;
|
|
37
|
+
const deleteAfterRun = input.deleteAfterRun ?? (input.schedule.kind === 'at');
|
|
38
|
+
|
|
39
|
+
const job = {
|
|
40
|
+
id: crypto.randomUUID(),
|
|
41
|
+
name: input.name,
|
|
42
|
+
description: input.description || undefined,
|
|
43
|
+
enabled,
|
|
44
|
+
deleteAfterRun,
|
|
45
|
+
createdAtMs: nowMs,
|
|
46
|
+
updatedAtMs: nowMs,
|
|
47
|
+
schedule: { ...input.schedule },
|
|
48
|
+
sessionTarget: input.sessionTarget || 'isolated',
|
|
49
|
+
wakeMode: input.wakeMode || 'now',
|
|
50
|
+
payload: { ...input.payload },
|
|
51
|
+
delivery: input.delivery ? { ...input.delivery } : undefined,
|
|
52
|
+
state: {
|
|
53
|
+
nextRunAtMs: undefined,
|
|
54
|
+
runningAtMs: undefined,
|
|
55
|
+
lastRunAtMs: undefined,
|
|
56
|
+
lastStatus: undefined,
|
|
57
|
+
lastError: undefined,
|
|
58
|
+
lastDurationMs: undefined,
|
|
59
|
+
consecutiveErrors: 0,
|
|
60
|
+
scheduleErrorCount: 0,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Compute initial next run
|
|
65
|
+
if (enabled) {
|
|
66
|
+
try {
|
|
67
|
+
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
68
|
+
} catch {
|
|
69
|
+
job.state.scheduleErrorCount = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return job;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Apply an update patch to a job.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} job - Existing job
|
|
80
|
+
* @param {object} patch - Fields to update
|
|
81
|
+
* @returns {object} Updated job (same reference, mutated)
|
|
82
|
+
*/
|
|
83
|
+
export function updateJob(job, patch) {
|
|
84
|
+
const nowMs = Date.now();
|
|
85
|
+
|
|
86
|
+
if (patch.name !== undefined) job.name = patch.name;
|
|
87
|
+
if (patch.description !== undefined) job.description = patch.description || undefined;
|
|
88
|
+
if (patch.deleteAfterRun !== undefined) job.deleteAfterRun = patch.deleteAfterRun;
|
|
89
|
+
if (patch.sessionTarget !== undefined) job.sessionTarget = patch.sessionTarget;
|
|
90
|
+
if (patch.wakeMode !== undefined) job.wakeMode = patch.wakeMode;
|
|
91
|
+
if (patch.payload !== undefined) job.payload = { ...patch.payload };
|
|
92
|
+
if (patch.delivery !== undefined) job.delivery = patch.delivery ? { ...patch.delivery } : undefined;
|
|
93
|
+
|
|
94
|
+
if (patch.schedule !== undefined) {
|
|
95
|
+
validateSchedule(patch.schedule);
|
|
96
|
+
job.schedule = { ...patch.schedule };
|
|
97
|
+
job.state.scheduleErrorCount = 0;
|
|
98
|
+
// Recompute next run
|
|
99
|
+
if (job.enabled) {
|
|
100
|
+
try {
|
|
101
|
+
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
102
|
+
} catch {
|
|
103
|
+
job.state.scheduleErrorCount = 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (patch.enabled !== undefined) {
|
|
109
|
+
job.enabled = patch.enabled;
|
|
110
|
+
if (job.enabled && !job.state.nextRunAtMs) {
|
|
111
|
+
try {
|
|
112
|
+
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
113
|
+
} catch {
|
|
114
|
+
job.state.scheduleErrorCount++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!job.enabled) {
|
|
118
|
+
job.state.nextRunAtMs = undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
job.updatedAtMs = nowMs;
|
|
123
|
+
return job;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Mark a job as started (running).
|
|
128
|
+
*/
|
|
129
|
+
export function markRunning(job) {
|
|
130
|
+
job.state.runningAtMs = Date.now();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Apply the result of a job execution.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} job - The job
|
|
137
|
+
* @param {"ok"|"error"|"skipped"} status - Execution result
|
|
138
|
+
* @param {string} [error] - Error message if status is "error"
|
|
139
|
+
* @param {number} [durationMs] - Execution duration
|
|
140
|
+
*/
|
|
141
|
+
export function applyResult(job, status, error, durationMs) {
|
|
142
|
+
const nowMs = Date.now();
|
|
143
|
+
|
|
144
|
+
job.state.lastRunAtMs = job.state.runningAtMs || nowMs;
|
|
145
|
+
job.state.runningAtMs = undefined;
|
|
146
|
+
job.state.lastStatus = status;
|
|
147
|
+
job.state.lastError = status === 'error' ? error : undefined;
|
|
148
|
+
job.state.lastDurationMs = durationMs;
|
|
149
|
+
|
|
150
|
+
if (status === 'error') {
|
|
151
|
+
job.state.consecutiveErrors = (job.state.consecutiveErrors || 0) + 1;
|
|
152
|
+
} else {
|
|
153
|
+
job.state.consecutiveErrors = 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One-shot jobs: disable after any terminal status
|
|
157
|
+
if (job.schedule.kind === 'at') {
|
|
158
|
+
job.enabled = false;
|
|
159
|
+
job.state.nextRunAtMs = undefined;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Recurring jobs: compute next run with backoff
|
|
164
|
+
if (job.enabled) {
|
|
165
|
+
try {
|
|
166
|
+
const normalNext = computeNextRunAtMs(job.schedule, nowMs);
|
|
167
|
+
if (normalNext === undefined) {
|
|
168
|
+
job.enabled = false;
|
|
169
|
+
job.state.nextRunAtMs = undefined;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (status === 'error' && job.state.consecutiveErrors > 0) {
|
|
174
|
+
const backoff = errorBackoffMs(job.state.consecutiveErrors);
|
|
175
|
+
job.state.nextRunAtMs = Math.max(normalNext, nowMs + backoff);
|
|
176
|
+
} else {
|
|
177
|
+
job.state.nextRunAtMs = normalNext;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
job.state.scheduleErrorCount = 0;
|
|
181
|
+
} catch {
|
|
182
|
+
job.state.scheduleErrorCount = (job.state.scheduleErrorCount || 0) + 1;
|
|
183
|
+
// Auto-disable after 3 consecutive schedule computation errors
|
|
184
|
+
if (job.state.scheduleErrorCount >= 3) {
|
|
185
|
+
job.enabled = false;
|
|
186
|
+
job.state.nextRunAtMs = undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check if a job is due to run.
|
|
194
|
+
*/
|
|
195
|
+
export function isDue(job, nowMs) {
|
|
196
|
+
return job.enabled
|
|
197
|
+
&& !job.state.runningAtMs
|
|
198
|
+
&& job.state.nextRunAtMs !== undefined
|
|
199
|
+
&& job.state.nextRunAtMs <= nowMs;
|
|
200
|
+
}
|
package/src/locked.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async locking mechanism to serialize state mutations.
|
|
3
|
+
* Prevents concurrent operations from corrupting job state.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let chain = Promise.resolve();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute a function with exclusive access to cron state.
|
|
10
|
+
* Operations queue behind each other — no concurrent mutations.
|
|
11
|
+
*
|
|
12
|
+
* @param {Function} fn - Async function to execute under lock
|
|
13
|
+
* @returns {Promise<*>} Result of fn
|
|
14
|
+
*/
|
|
15
|
+
export async function locked(fn) {
|
|
16
|
+
let resolve;
|
|
17
|
+
const prev = chain;
|
|
18
|
+
chain = new Promise(r => { resolve = r; });
|
|
19
|
+
|
|
20
|
+
await prev;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} finally {
|
|
25
|
+
resolve();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reset the lock chain. Only for testing.
|
|
31
|
+
*/
|
|
32
|
+
export function resetLock() {
|
|
33
|
+
chain = Promise.resolve();
|
|
34
|
+
}
|
package/src/normalize.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input normalization for AI-generated job definitions.
|
|
3
|
+
* Handles imperfect JSON from AI models: wrong casing, missing fields,
|
|
4
|
+
* flat-param recovery, type coercion.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize a schedule object. Infers kind from fields if missing.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeSchedule(raw) {
|
|
11
|
+
if (!raw || typeof raw !== 'object') return raw;
|
|
12
|
+
|
|
13
|
+
const schedule = { ...raw };
|
|
14
|
+
|
|
15
|
+
// Infer kind from fields if missing
|
|
16
|
+
if (!schedule.kind) {
|
|
17
|
+
if (schedule.at || schedule.atMs) schedule.kind = 'at';
|
|
18
|
+
else if (schedule.everyMs) schedule.kind = 'every';
|
|
19
|
+
else if (schedule.expr) schedule.kind = 'cron';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Case normalization
|
|
23
|
+
if (typeof schedule.kind === 'string') {
|
|
24
|
+
schedule.kind = schedule.kind.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Legacy: atMs (number) → at (ISO string)
|
|
28
|
+
if (schedule.atMs && !schedule.at) {
|
|
29
|
+
schedule.at = new Date(schedule.atMs).toISOString();
|
|
30
|
+
delete schedule.atMs;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Coerce string everyMs to number
|
|
34
|
+
if (typeof schedule.everyMs === 'string') {
|
|
35
|
+
schedule.everyMs = Number(schedule.everyMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return schedule;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a payload object. Infers kind from fields if missing.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizePayload(raw) {
|
|
45
|
+
if (!raw || typeof raw !== 'object') return raw;
|
|
46
|
+
|
|
47
|
+
const payload = { ...raw };
|
|
48
|
+
|
|
49
|
+
// Infer kind from fields
|
|
50
|
+
if (!payload.kind) {
|
|
51
|
+
if (payload.message) payload.kind = 'agentTurn';
|
|
52
|
+
else if (payload.text) payload.kind = 'systemEvent';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Case normalization
|
|
56
|
+
if (typeof payload.kind === 'string') {
|
|
57
|
+
const lower = payload.kind.toLowerCase();
|
|
58
|
+
if (lower === 'agentturn') payload.kind = 'agentTurn';
|
|
59
|
+
else if (lower === 'systemevent') payload.kind = 'systemEvent';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return payload;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Recover a job object from flat parameters.
|
|
67
|
+
* AI models sometimes flatten nested fields to the top level.
|
|
68
|
+
*/
|
|
69
|
+
const JOB_KEYS = new Set([
|
|
70
|
+
'name', 'description', 'schedule', 'sessionTarget', 'payload',
|
|
71
|
+
'delivery', 'enabled', 'deleteAfterRun', 'wakeMode',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
export function recoverFlatParams(params) {
|
|
75
|
+
if (params.job && typeof params.job === 'object' && Object.keys(params.job).length > 0) {
|
|
76
|
+
return params.job;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const synthetic = {};
|
|
80
|
+
for (const key of Object.keys(params)) {
|
|
81
|
+
if (JOB_KEYS.has(key)) {
|
|
82
|
+
synthetic[key] = params[key];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// message/text are not JOB_KEYS but need to be recovered for payload wrapping
|
|
87
|
+
const message = params.message;
|
|
88
|
+
const text = params.text;
|
|
89
|
+
|
|
90
|
+
if (synthetic.schedule || synthetic.payload || message || text) {
|
|
91
|
+
// If message/text are at top level, wrap into payload
|
|
92
|
+
if (!synthetic.payload) {
|
|
93
|
+
if (message) {
|
|
94
|
+
synthetic.payload = { kind: 'agentTurn', message };
|
|
95
|
+
} else if (text) {
|
|
96
|
+
synthetic.payload = { kind: 'systemEvent', text };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return synthetic;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return params;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalize a complete job input for creation.
|
|
107
|
+
* Applies all normalization: schedule, payload, defaults.
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeJobInput(raw) {
|
|
110
|
+
const job = { ...raw };
|
|
111
|
+
|
|
112
|
+
if (job.schedule) {
|
|
113
|
+
job.schedule = normalizeSchedule(job.schedule);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (job.payload) {
|
|
117
|
+
job.payload = normalizePayload(job.payload);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Default: enabled
|
|
121
|
+
if (job.enabled === undefined) job.enabled = true;
|
|
122
|
+
|
|
123
|
+
// Default: wakeMode
|
|
124
|
+
if (!job.wakeMode) job.wakeMode = 'now';
|
|
125
|
+
|
|
126
|
+
// Default: sessionTarget inferred from payload
|
|
127
|
+
if (!job.sessionTarget && job.payload) {
|
|
128
|
+
job.sessionTarget = job.payload.kind === 'systemEvent' ? 'main' : 'isolated';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Default: deleteAfterRun for one-shot
|
|
132
|
+
if (job.deleteAfterRun === undefined && job.schedule?.kind === 'at') {
|
|
133
|
+
job.deleteAfterRun = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Default: delivery for isolated agentTurn
|
|
137
|
+
if (!job.delivery && job.sessionTarget === 'isolated' && job.payload?.kind === 'agentTurn') {
|
|
138
|
+
job.delivery = { mode: 'announce' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Auto-generate name if missing
|
|
142
|
+
if (!job.name) {
|
|
143
|
+
job.name = inferName(job);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return job;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Infer a job name from schedule and payload.
|
|
151
|
+
*/
|
|
152
|
+
function inferName(job) {
|
|
153
|
+
const parts = [];
|
|
154
|
+
|
|
155
|
+
if (job.schedule?.kind === 'at') parts.push('One-shot');
|
|
156
|
+
else if (job.schedule?.kind === 'every') parts.push('Recurring');
|
|
157
|
+
else if (job.schedule?.kind === 'cron') parts.push('Scheduled');
|
|
158
|
+
|
|
159
|
+
if (job.payload?.kind === 'agentTurn') parts.push('agent task');
|
|
160
|
+
else if (job.payload?.kind === 'systemEvent') parts.push('system event');
|
|
161
|
+
|
|
162
|
+
return parts.join(' ') || 'Unnamed job';
|
|
163
|
+
}
|
package/src/run-log.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory run log for job execution history.
|
|
3
|
+
* Stores recent execution results per job with auto-pruning.
|
|
4
|
+
*
|
|
5
|
+
* Persistence (via ORM) is added in PR 2.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_ENTRIES_PER_JOB = 100;
|
|
9
|
+
|
|
10
|
+
export default class RunLog {
|
|
11
|
+
constructor(maxEntriesPerJob = DEFAULT_MAX_ENTRIES_PER_JOB) {
|
|
12
|
+
this.maxEntries = maxEntriesPerJob;
|
|
13
|
+
this.entries = new Map(); // jobId → RunLogEntry[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Record a job execution result.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} entry
|
|
20
|
+
* @param {string} entry.jobId
|
|
21
|
+
* @param {"ok"|"error"|"skipped"} entry.status
|
|
22
|
+
* @param {string} [entry.error]
|
|
23
|
+
* @param {string} [entry.summary]
|
|
24
|
+
* @param {number} [entry.runAtMs]
|
|
25
|
+
* @param {number} [entry.durationMs]
|
|
26
|
+
* @param {number} [entry.nextRunAtMs]
|
|
27
|
+
*/
|
|
28
|
+
record(entry) {
|
|
29
|
+
const log = {
|
|
30
|
+
ts: Date.now(),
|
|
31
|
+
jobId: entry.jobId,
|
|
32
|
+
status: entry.status,
|
|
33
|
+
error: entry.error,
|
|
34
|
+
summary: entry.summary,
|
|
35
|
+
runAtMs: entry.runAtMs,
|
|
36
|
+
durationMs: entry.durationMs,
|
|
37
|
+
nextRunAtMs: entry.nextRunAtMs,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (!this.entries.has(entry.jobId)) {
|
|
41
|
+
this.entries.set(entry.jobId, []);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const logs = this.entries.get(entry.jobId);
|
|
45
|
+
logs.push(log);
|
|
46
|
+
|
|
47
|
+
// Auto-prune
|
|
48
|
+
if (logs.length > this.maxEntries) {
|
|
49
|
+
logs.splice(0, logs.length - this.maxEntries);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get run history for a job.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} jobId
|
|
57
|
+
* @param {number} [limit=20]
|
|
58
|
+
* @returns {object[]} Most recent entries, newest first
|
|
59
|
+
*/
|
|
60
|
+
get(jobId, limit = 20) {
|
|
61
|
+
const logs = this.entries.get(jobId);
|
|
62
|
+
if (!logs) return [];
|
|
63
|
+
return logs.slice(-limit).reverse();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remove all entries for a job.
|
|
68
|
+
*/
|
|
69
|
+
removeJob(jobId) {
|
|
70
|
+
this.entries.delete(jobId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Clear all entries.
|
|
75
|
+
*/
|
|
76
|
+
clear() {
|
|
77
|
+
this.entries.clear();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/schedule.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule types and next-run computation.
|
|
3
|
+
*
|
|
4
|
+
* Three schedule kinds:
|
|
5
|
+
* - "at": One-shot at an absolute ISO-8601 timestamp
|
|
6
|
+
* - "every": Recurring interval in milliseconds
|
|
7
|
+
* - "cron": 5-field cron expression with optional timezone
|
|
8
|
+
*/
|
|
9
|
+
import { nextOccurrence, validateCronExpression } from './cron-parser.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compute the next run time for a schedule.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} schedule - Schedule definition
|
|
15
|
+
* @param {string} schedule.kind - "at" | "every" | "cron"
|
|
16
|
+
* @param {number} nowMs - Current time in milliseconds
|
|
17
|
+
* @returns {number|undefined} Next run time in ms, or undefined if no future occurrence
|
|
18
|
+
*/
|
|
19
|
+
export function computeNextRunAtMs(schedule, nowMs) {
|
|
20
|
+
if (schedule.kind === 'at') {
|
|
21
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
22
|
+
if (!Number.isFinite(atMs)) return undefined;
|
|
23
|
+
return atMs > nowMs ? atMs : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (schedule.kind === 'every') {
|
|
27
|
+
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
|
28
|
+
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
|
29
|
+
|
|
30
|
+
if (nowMs < anchor) return anchor;
|
|
31
|
+
|
|
32
|
+
const elapsed = nowMs - anchor;
|
|
33
|
+
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
|
|
34
|
+
return anchor + steps * everyMs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (schedule.kind === 'cron') {
|
|
38
|
+
const tz = schedule.tz?.trim() || undefined;
|
|
39
|
+
// Round nowMs down to the current second to avoid sub-second drift
|
|
40
|
+
const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
|
|
41
|
+
return nextOccurrence(schedule.expr.trim(), nowSecondMs, tz);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate a schedule definition.
|
|
49
|
+
* @param {object} schedule
|
|
50
|
+
* @throws {Error} if the schedule is invalid
|
|
51
|
+
*/
|
|
52
|
+
export function validateSchedule(schedule) {
|
|
53
|
+
if (!schedule || typeof schedule !== 'object') {
|
|
54
|
+
throw new Error('Schedule must be an object');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (schedule.kind === 'at') {
|
|
58
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
59
|
+
if (!Number.isFinite(atMs)) {
|
|
60
|
+
throw new Error(`Invalid "at" timestamp: "${schedule.at}"`);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (schedule.kind === 'every') {
|
|
66
|
+
if (typeof schedule.everyMs !== 'number' || schedule.everyMs < 1) {
|
|
67
|
+
throw new Error(`"every" schedule requires everyMs >= 1, got: ${schedule.everyMs}`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (schedule.kind === 'cron') {
|
|
73
|
+
if (typeof schedule.expr !== 'string' || !schedule.expr.trim()) {
|
|
74
|
+
throw new Error('"cron" schedule requires a non-empty expr string');
|
|
75
|
+
}
|
|
76
|
+
validateCronExpression(schedule.expr.trim());
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
81
|
+
}
|
package/src/service.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CronService — the main API for advanced job scheduling.
|
|
3
|
+
*
|
|
4
|
+
* Manages jobs in memory with a min-heap for efficient next-job lookup.
|
|
5
|
+
* Supports pluggable store interface (memory-only by default, ORM in PR 2).
|
|
6
|
+
* All state mutations are serialized via async locking.
|
|
7
|
+
*/
|
|
8
|
+
import config from 'stonyx/config';
|
|
9
|
+
import log from 'stonyx/log';
|
|
10
|
+
import MinHeap from './min-heap.js';
|
|
11
|
+
import { createJob, updateJob, markRunning, applyResult, isDue } from './job.js';
|
|
12
|
+
import { computeNextRunAtMs } from './schedule.js';
|
|
13
|
+
import { locked } from './locked.js';
|
|
14
|
+
import { normalizeJobInput, recoverFlatParams } from './normalize.js';
|
|
15
|
+
import RunLog from './run-log.js';
|
|
16
|
+
|
|
17
|
+
const MAX_TIMER_DELAY_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
export default class CronService {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.jobs = new Map(); // id → job
|
|
22
|
+
this.heap = new MinHeap(); // ordered by nextRunAtMs
|
|
23
|
+
this.timer = null;
|
|
24
|
+
this.running = false;
|
|
25
|
+
this.runLog = new RunLog();
|
|
26
|
+
this.started = false;
|
|
27
|
+
|
|
28
|
+
// Pluggable callbacks for consumers
|
|
29
|
+
this.onJobDue = null; // async (job) => { status, error?, summary? }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Lifecycle ──────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start the service. Loads jobs from store (if any), arms timer.
|
|
36
|
+
*/
|
|
37
|
+
async start(initialJobs) {
|
|
38
|
+
if (this.started) return;
|
|
39
|
+
this.started = true;
|
|
40
|
+
|
|
41
|
+
if (initialJobs) {
|
|
42
|
+
for (const job of initialJobs) {
|
|
43
|
+
this.jobs.set(job.id, job);
|
|
44
|
+
if (job.enabled && job.state.nextRunAtMs) {
|
|
45
|
+
this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.armTimer();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stop the service. Clears timer.
|
|
55
|
+
*/
|
|
56
|
+
stop() {
|
|
57
|
+
this.started = false;
|
|
58
|
+
clearTimeout(this.timer);
|
|
59
|
+
this.timer = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── CRUD ───────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get service status.
|
|
66
|
+
*/
|
|
67
|
+
status() {
|
|
68
|
+
const peek = this.heap.peek();
|
|
69
|
+
return {
|
|
70
|
+
started: this.started,
|
|
71
|
+
jobCount: this.jobs.size,
|
|
72
|
+
nextWakeAtMs: peek ? peek.nextTrigger : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* List jobs, optionally including disabled ones.
|
|
78
|
+
*/
|
|
79
|
+
list(opts) {
|
|
80
|
+
const includeDisabled = opts?.includeDisabled ?? false;
|
|
81
|
+
const jobs = [...this.jobs.values()];
|
|
82
|
+
const filtered = includeDisabled ? jobs : jobs.filter(j => j.enabled);
|
|
83
|
+
return filtered.sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get a single job by ID.
|
|
88
|
+
*/
|
|
89
|
+
get(id) {
|
|
90
|
+
return this.jobs.get(id) || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Add a new job. Input is normalized for AI compatibility.
|
|
95
|
+
*/
|
|
96
|
+
async add(rawInput) {
|
|
97
|
+
return locked(() => {
|
|
98
|
+
const input = normalizeJobInput(recoverFlatParams(rawInput));
|
|
99
|
+
const job = createJob(input);
|
|
100
|
+
this.jobs.set(job.id, job);
|
|
101
|
+
|
|
102
|
+
if (job.enabled && job.state.nextRunAtMs) {
|
|
103
|
+
this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
|
|
104
|
+
this.armTimer();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return job;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Update an existing job.
|
|
113
|
+
*/
|
|
114
|
+
async update(id, patch) {
|
|
115
|
+
return locked(() => {
|
|
116
|
+
const job = this.jobs.get(id);
|
|
117
|
+
if (!job) throw new Error(`Job not found: ${id}`);
|
|
118
|
+
|
|
119
|
+
const oldNextRun = job.state.nextRunAtMs;
|
|
120
|
+
updateJob(job, patch);
|
|
121
|
+
|
|
122
|
+
// Update heap entry
|
|
123
|
+
this.removeFromHeap(id);
|
|
124
|
+
if (job.enabled && job.state.nextRunAtMs) {
|
|
125
|
+
this.heap.push({ key: id, nextTrigger: job.state.nextRunAtMs });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (job.state.nextRunAtMs !== oldNextRun) {
|
|
129
|
+
this.armTimer();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return job;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Remove a job.
|
|
138
|
+
*/
|
|
139
|
+
async remove(id) {
|
|
140
|
+
return locked(() => {
|
|
141
|
+
const job = this.jobs.get(id);
|
|
142
|
+
if (!job) throw new Error(`Job not found: ${id}`);
|
|
143
|
+
|
|
144
|
+
this.jobs.delete(id);
|
|
145
|
+
this.removeFromHeap(id);
|
|
146
|
+
this.runLog.removeJob(id);
|
|
147
|
+
this.armTimer();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Manually trigger a job.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} id - Job ID
|
|
155
|
+
* @param {"due"|"force"} [mode="force"] - "due" only runs if the job is due, "force" runs regardless
|
|
156
|
+
*/
|
|
157
|
+
async run(id, mode = 'force') {
|
|
158
|
+
const job = this.jobs.get(id);
|
|
159
|
+
if (!job) throw new Error(`Job not found: ${id}`);
|
|
160
|
+
|
|
161
|
+
if (mode === 'due' && !isDue(job, Date.now())) {
|
|
162
|
+
return { status: 'skipped', reason: 'not due' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return this.executeJob(job);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get run history for a job.
|
|
170
|
+
*/
|
|
171
|
+
runs(id, limit) {
|
|
172
|
+
return this.runLog.get(id, limit);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Timer Engine ──────────────────────────────────────��────
|
|
176
|
+
|
|
177
|
+
armTimer() {
|
|
178
|
+
clearTimeout(this.timer);
|
|
179
|
+
if (!this.started) return;
|
|
180
|
+
|
|
181
|
+
const peek = this.heap.peek();
|
|
182
|
+
if (!peek) return;
|
|
183
|
+
|
|
184
|
+
const delay = Math.min(Math.max(peek.nextTrigger - Date.now(), 0), MAX_TIMER_DELAY_MS);
|
|
185
|
+
this.timer = setTimeout(() => this.onTimer(), delay);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async onTimer() {
|
|
189
|
+
if (this.running) {
|
|
190
|
+
// Already processing — re-arm at max delay to prevent scheduler death
|
|
191
|
+
this.timer = setTimeout(() => this.onTimer(), MAX_TIMER_DELAY_MS);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.running = true;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await locked(async () => {
|
|
199
|
+
const nowMs = Date.now();
|
|
200
|
+
const dueJobs = this.findDueJobs(nowMs);
|
|
201
|
+
|
|
202
|
+
for (const job of dueJobs) {
|
|
203
|
+
markRunning(job);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const job of dueJobs) {
|
|
207
|
+
await this.executeJob(job);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
} finally {
|
|
211
|
+
this.running = false;
|
|
212
|
+
this.armTimer();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
findDueJobs(nowMs) {
|
|
217
|
+
const due = [];
|
|
218
|
+
|
|
219
|
+
while (!this.heap.isEmpty()) {
|
|
220
|
+
const peek = this.heap.peek();
|
|
221
|
+
if (peek.nextTrigger > nowMs) break;
|
|
222
|
+
|
|
223
|
+
this.heap.pop();
|
|
224
|
+
const job = this.jobs.get(peek.key);
|
|
225
|
+
if (job && isDue(job, nowMs)) {
|
|
226
|
+
due.push(job);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return due;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async executeJob(job) {
|
|
234
|
+
const startMs = Date.now();
|
|
235
|
+
let status = 'ok';
|
|
236
|
+
let error;
|
|
237
|
+
let summary;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
if (this.onJobDue) {
|
|
241
|
+
const result = await this.onJobDue(job);
|
|
242
|
+
if (result) {
|
|
243
|
+
status = result.status || 'ok';
|
|
244
|
+
error = result.error;
|
|
245
|
+
summary = result.summary;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
status = 'error';
|
|
250
|
+
error = err?.message || String(err);
|
|
251
|
+
this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const durationMs = Date.now() - startMs;
|
|
255
|
+
|
|
256
|
+
applyResult(job, status, error, durationMs);
|
|
257
|
+
|
|
258
|
+
// Log the run
|
|
259
|
+
this.runLog.record({
|
|
260
|
+
jobId: job.id,
|
|
261
|
+
status,
|
|
262
|
+
error,
|
|
263
|
+
summary,
|
|
264
|
+
runAtMs: startMs,
|
|
265
|
+
durationMs,
|
|
266
|
+
nextRunAtMs: job.state.nextRunAtMs,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Handle one-shot auto-delete
|
|
270
|
+
if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
|
|
271
|
+
this.jobs.delete(job.id);
|
|
272
|
+
this.runLog.removeJob(job.id);
|
|
273
|
+
return { status, summary, deleted: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Re-insert into heap if still active
|
|
277
|
+
if (job.enabled && job.state.nextRunAtMs) {
|
|
278
|
+
this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { status, error, summary, durationMs };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Helpers ────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
removeFromHeap(id) {
|
|
287
|
+
// MinHeap doesn't support remove-by-key efficiently,
|
|
288
|
+
// so we rebuild. Fine for typical job counts (< 1000).
|
|
289
|
+
const remaining = [];
|
|
290
|
+
while (!this.heap.isEmpty()) {
|
|
291
|
+
const item = this.heap.pop();
|
|
292
|
+
if (item.key !== id) remaining.push(item);
|
|
293
|
+
}
|
|
294
|
+
for (const item of remaining) {
|
|
295
|
+
this.heap.push(item);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
log(message) {
|
|
300
|
+
if (!config.cron?.log) return;
|
|
301
|
+
log.cron(`Cron — ${message}`);
|
|
302
|
+
}
|
|
303
|
+
}
|