@amenophis1er/foreman 0.1.16 → 0.1.18
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 +74 -3
- package/package.json +1 -1
- package/src/crew.test.ts +376 -0
- package/src/crew.ts +330 -0
- package/src/fleet-planner.test.ts +39 -1
- package/src/fleet-planner.ts +83 -3
- package/src/gitwork.test.ts +133 -2
- package/src/gitwork.ts +211 -2
- package/src/mcp.test.ts +38 -1
- package/src/mcp.ts +86 -5
- package/src/notify/commands.test.ts +2 -0
- package/src/notify/commands.ts +6 -0
- package/src/notify/telegram.ts +1 -0
- package/src/notify.test.ts +61 -0
- package/src/notify.ts +45 -1
- package/src/orchestrator.test.ts +516 -2
- package/src/orchestrator.ts +514 -69
- package/src/run-crew.test.ts +99 -0
- package/src/run-crew.ts +101 -0
- package/src/schedule-guards.test.ts +236 -0
- package/src/schedule-guards.ts +149 -0
- package/src/schedule.test.ts +240 -0
- package/src/schedule.ts +343 -0
- package/src/server.ts +674 -11
- package/src/store.test.ts +81 -1
- package/src/store.ts +117 -3
- package/src/types.ts +92 -0
- package/ui/dist/assets/index-0QuGXbFg.js +76 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-DOVnExqF.js +0 -68
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Scheduling is wall-clock arithmetic, so the tests have to own the clock:
|
|
2
|
+
// pin the zone before anything reads a Date, and skip the DST assertions on a
|
|
3
|
+
// machine where the pin did not take (Node re-reads process.env.TZ, but a
|
|
4
|
+
// container without tzdata cannot honour it).
|
|
5
|
+
process.env.TZ = 'America/New_York';
|
|
6
|
+
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import {
|
|
10
|
+
describeCadence, nextRunAt, nextRuns, parseCron, validateCadence, type Cadence,
|
|
11
|
+
} from './schedule.js';
|
|
12
|
+
|
|
13
|
+
/** Is the process actually in US Eastern, offsets and all? */
|
|
14
|
+
const EASTERN = new Date(2026, 0, 15, 12, 0).getTimezoneOffset() === 300
|
|
15
|
+
&& new Date(2026, 6, 15, 12, 0).getTimezoneOffset() === 240;
|
|
16
|
+
|
|
17
|
+
/** A local-time Date, written the way the assertions read. */
|
|
18
|
+
const local = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s, 0);
|
|
19
|
+
|
|
20
|
+
/** 'YYYY-MM-DD HH:MM' in local time — what a failure message should show. */
|
|
21
|
+
const show = (d: Date | null) => (d === null ? 'null' : [
|
|
22
|
+
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
|
|
23
|
+
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
|
24
|
+
].join(' '));
|
|
25
|
+
|
|
26
|
+
const at = (cadence: Cadence, after: Date) => show(nextRunAt(cadence, after));
|
|
27
|
+
|
|
28
|
+
test('daily fires at its wall-clock time, and rolls to tomorrow once past', () => {
|
|
29
|
+
const daily: Cadence = { kind: 'daily', at: '07:30' };
|
|
30
|
+
assert.equal(at(daily, local(2026, 3, 2, 6, 0)), '2026-03-02 07:30');
|
|
31
|
+
assert.equal(at(daily, local(2026, 3, 2, 7, 29, 59)), '2026-03-02 07:30');
|
|
32
|
+
// Strictly after: standing exactly on the firing minute means tomorrow.
|
|
33
|
+
assert.equal(at(daily, local(2026, 3, 2, 7, 30)), '2026-03-03 07:30');
|
|
34
|
+
assert.equal(at(daily, local(2026, 3, 2, 8, 0)), '2026-03-03 07:30');
|
|
35
|
+
// Seconds and milliseconds are never carried into a firing.
|
|
36
|
+
const next = nextRunAt(daily, local(2026, 3, 2, 6, 0, 42))!;
|
|
37
|
+
assert.equal(next.getSeconds(), 0);
|
|
38
|
+
assert.equal(next.getMilliseconds(), 0);
|
|
39
|
+
// Month and year roll on their own.
|
|
40
|
+
assert.equal(at(daily, local(2026, 12, 31, 9, 0)), '2027-01-01 07:30');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('weekly fires on its day, rolling a whole week when the day has passed', () => {
|
|
44
|
+
const monday: Cadence = { kind: 'weekly', day: 1, at: '09:00' };
|
|
45
|
+
// 2026-03-04 is a Wednesday.
|
|
46
|
+
assert.equal(local(2026, 3, 4).getDay(), 3);
|
|
47
|
+
assert.equal(at(monday, local(2026, 3, 4, 12, 0)), '2026-03-09 09:00');
|
|
48
|
+
// On the day, before and after the hour.
|
|
49
|
+
assert.equal(at(monday, local(2026, 3, 9, 8, 59)), '2026-03-09 09:00');
|
|
50
|
+
assert.equal(at(monday, local(2026, 3, 9, 9, 0)), '2026-03-16 09:00');
|
|
51
|
+
// Sunday is 0, not 7.
|
|
52
|
+
assert.equal(at({ kind: 'weekly', day: 0, at: '23:00' }, local(2026, 3, 4, 12, 0)), '2026-03-08 23:00');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('interval is elapsed time from `after`, to the millisecond', () => {
|
|
56
|
+
const six: Cadence = { kind: 'interval', everyMinutes: 360 };
|
|
57
|
+
const from = local(2026, 5, 4, 10, 17, 33);
|
|
58
|
+
assert.equal(nextRunAt(six, from)!.getTime() - from.getTime(), 360 * 60_000);
|
|
59
|
+
const ninety = nextRunAt({ kind: 'interval', everyMinutes: 90 }, from)!;
|
|
60
|
+
assert.equal(ninety.getTime() - from.getTime(), 90 * 60_000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('cron: single values, lists, ranges and steps', () => {
|
|
64
|
+
assert.equal(at({ kind: 'cron', expr: '0 9 * * *' }, local(2026, 3, 2, 12, 0)), '2026-03-03 09:00');
|
|
65
|
+
// A list of minutes inside the hour.
|
|
66
|
+
const list: Cadence = { kind: 'cron', expr: '1,15,45 * * * *' };
|
|
67
|
+
assert.equal(at(list, local(2026, 3, 2, 12, 2)), '2026-03-02 12:15');
|
|
68
|
+
assert.equal(at(list, local(2026, 3, 2, 12, 45)), '2026-03-02 13:01');
|
|
69
|
+
// A range of weekdays: Friday 09:00 jumps to Monday.
|
|
70
|
+
const weekdays: Cadence = { kind: 'cron', expr: '0 9 * * 1-5' };
|
|
71
|
+
assert.equal(local(2026, 3, 6).getDay(), 5);
|
|
72
|
+
assert.equal(at(weekdays, local(2026, 3, 6, 9, 0)), '2026-03-09 09:00');
|
|
73
|
+
// Steps, on the minute field and on a range.
|
|
74
|
+
assert.equal(at({ kind: 'cron', expr: '*/15 * * * *' }, local(2026, 3, 2, 12, 1)), '2026-03-02 12:15');
|
|
75
|
+
assert.equal(at({ kind: 'cron', expr: '10-50/20 * * * *' }, local(2026, 3, 2, 12, 31)), '2026-03-02 12:50');
|
|
76
|
+
assert.equal(at({ kind: 'cron', expr: '10-50/20 * * * *' }, local(2026, 3, 2, 12, 51)), '2026-03-02 13:10');
|
|
77
|
+
// An hour step lands on the next multiple, not the next hour.
|
|
78
|
+
assert.equal(at({ kind: 'cron', expr: '0 */6 * * *' }, local(2026, 3, 2, 7, 0)), '2026-03-02 12:00');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('parseCron: fields come back sorted, and 7 means Sunday', () => {
|
|
82
|
+
const c = parseCron('45,15 1-3 1,15 */6 7');
|
|
83
|
+
assert.deepEqual(c.minutes, [15, 45]);
|
|
84
|
+
assert.deepEqual(c.hours, [1, 2, 3]);
|
|
85
|
+
assert.deepEqual(c.daysOfMonth, [1, 15]);
|
|
86
|
+
assert.deepEqual(c.months, [1, 7]);
|
|
87
|
+
assert.deepEqual(c.daysOfWeek, [0]);
|
|
88
|
+
assert.equal(c.domRestricted, true);
|
|
89
|
+
assert.equal(c.dowRestricted, true);
|
|
90
|
+
const star = parseCron('0 0 * * *');
|
|
91
|
+
assert.equal(star.domRestricted, false);
|
|
92
|
+
assert.equal(star.dowRestricted, false);
|
|
93
|
+
assert.equal(star.daysOfMonth.length, 31);
|
|
94
|
+
assert.equal(star.daysOfWeek.length, 7);
|
|
95
|
+
// Extra whitespace between fields is not an error.
|
|
96
|
+
assert.deepEqual(parseCron(' 0 9 * * 1 ').hours, [9]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('cron: with both day fields restricted, either one may match', () => {
|
|
100
|
+
// "the 1st of the month, and every Monday" — not the intersection.
|
|
101
|
+
const both: Cadence = { kind: 'cron', expr: '0 0 1 * 1' };
|
|
102
|
+
// 2026-04-01 is a Wednesday; it fires on the date alone.
|
|
103
|
+
assert.equal(local(2026, 4, 1).getDay(), 3);
|
|
104
|
+
assert.equal(at(both, local(2026, 3, 31, 12, 0)), '2026-04-01 00:00');
|
|
105
|
+
// and on the next Monday, which is not the 1st.
|
|
106
|
+
assert.equal(at(both, local(2026, 4, 1, 0, 0)), '2026-04-06 00:00');
|
|
107
|
+
assert.equal(local(2026, 4, 6).getDay(), 1);
|
|
108
|
+
// One field restricted is a plain AND with the other's "every".
|
|
109
|
+
assert.equal(at({ kind: 'cron', expr: '0 0 1 * *' }, local(2026, 4, 1, 0, 0)), '2026-05-01 00:00');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('cron: month ends, February, and a leap day', () => {
|
|
113
|
+
// The 31st skips the 30-day months entirely: Jan 31 → Mar 31.
|
|
114
|
+
assert.equal(at({ kind: 'cron', expr: '0 0 31 * *' }, local(2026, 1, 31, 0, 0)), '2026-03-31 00:00');
|
|
115
|
+
assert.equal(at({ kind: 'cron', expr: '0 0 31 * *' }, local(2026, 4, 1, 0, 0)), '2026-05-31 00:00');
|
|
116
|
+
// A February end-of-month rule lands on the 28th in a common year...
|
|
117
|
+
assert.equal(at({ kind: 'cron', expr: '0 12 28-31 2 *' }, local(2026, 2, 27, 0, 0)), '2026-02-28 12:00');
|
|
118
|
+
assert.equal(at({ kind: 'cron', expr: '0 12 28-31 2 *' }, local(2026, 2, 28, 12, 0)), '2027-02-28 12:00');
|
|
119
|
+
// ...and on the 29th in a leap year. 2028 is one; 2100 would not be.
|
|
120
|
+
assert.equal(at({ kind: 'cron', expr: '0 12 29 2 *' }, local(2026, 1, 1, 0, 0)), '2028-02-29 12:00');
|
|
121
|
+
// February 30th never happens; the scan gives up instead of hanging.
|
|
122
|
+
assert.equal(nextRunAt({ kind: 'cron', expr: '0 0 30 2 *' }, local(2026, 1, 1, 0, 0)), null);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('interval survives a DST change; wall-clock cadences keep the clock time', { skip: EASTERN ? false : 'not in America/New_York' }, () => {
|
|
126
|
+
// 2026-03-08 is spring forward in US Eastern: 02:00 becomes 03:00.
|
|
127
|
+
assert.equal(new Date(2026, 2, 8, 0, 30).getTimezoneOffset(), 300);
|
|
128
|
+
assert.equal(new Date(2026, 2, 8, 12, 0).getTimezoneOffset(), 240);
|
|
129
|
+
|
|
130
|
+
// Six hours means six hours: 00:30 EST + 6 h reads 07:30 EDT on the wall.
|
|
131
|
+
const from = local(2026, 3, 8, 0, 30);
|
|
132
|
+
const six = nextRunAt({ kind: 'interval', everyMinutes: 360 }, from)!;
|
|
133
|
+
assert.equal(six.getTime() - from.getTime(), 6 * 3_600_000);
|
|
134
|
+
assert.equal(show(six), '2026-03-08 07:30');
|
|
135
|
+
|
|
136
|
+
// 02:30 does not exist that morning. It must still resolve to a real
|
|
137
|
+
// instant just after the skip, not hang and not vanish.
|
|
138
|
+
const daily: Cadence = { kind: 'daily', at: '02:30' };
|
|
139
|
+
const skipped = nextRunAt(daily, local(2026, 3, 7, 12, 0))!;
|
|
140
|
+
assert.ok(skipped.getTime() > local(2026, 3, 7, 12, 0).getTime());
|
|
141
|
+
assert.equal(show(skipped), '2026-03-08 03:30');
|
|
142
|
+
// and the day after is an ordinary 02:30 again.
|
|
143
|
+
assert.equal(show(nextRunAt(daily, skipped)), '2026-03-09 02:30');
|
|
144
|
+
|
|
145
|
+
// Fall back (2026-11-01, 02:00 becomes 01:00): the hour repeats, and a
|
|
146
|
+
// daily 01:30 fires once, then the next day.
|
|
147
|
+
const back = nextRunAt({ kind: 'daily', at: '01:30' }, local(2026, 11, 1, 0, 0))!;
|
|
148
|
+
assert.equal(show(back), '2026-11-01 01:30');
|
|
149
|
+
assert.equal(show(nextRunAt({ kind: 'daily', at: '01:30' }, back)), '2026-11-02 01:30');
|
|
150
|
+
|
|
151
|
+
// A whole DST week of firings stays strictly increasing.
|
|
152
|
+
const week = nextRuns({ kind: 'daily', at: '02:30' }, local(2026, 3, 6, 12, 0), 5);
|
|
153
|
+
assert.equal(week.length, 5);
|
|
154
|
+
for (let i = 1; i < week.length; i++) assert.ok(week[i].getTime() > week[i - 1].getTime());
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('parseCron: bad expressions say which field and why', () => {
|
|
158
|
+
const why = (expr: string) => {
|
|
159
|
+
try {
|
|
160
|
+
parseCron(expr);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return (e as Error).message;
|
|
163
|
+
}
|
|
164
|
+
assert.fail(`expected ${JSON.stringify(expr)} to be rejected`);
|
|
165
|
+
};
|
|
166
|
+
assert.match(why('62 * * * *'), /minute: "62" is out of range 0-59/);
|
|
167
|
+
assert.match(why('0 24 * * *'), /hour: "24" is out of range 0-23/);
|
|
168
|
+
assert.match(why('0 0 0 * *'), /day-of-month: "0" is out of range 1-31/);
|
|
169
|
+
assert.match(why('0 0 * 13 *'), /month: "13" is out of range 1-12/);
|
|
170
|
+
assert.match(why('0 0 * * 8'), /day-of-week: "8" is out of range 0-7/);
|
|
171
|
+
assert.match(why('0 0 * * mon'), /day-of-week: "mon" is out of range/);
|
|
172
|
+
assert.match(why('0 0 * *'), /five fields.*got 4/);
|
|
173
|
+
assert.match(why('0 0 * * * *'), /five fields.*got 6/);
|
|
174
|
+
assert.match(why(''), /five fields.*got 0/);
|
|
175
|
+
assert.match(why('5-1 * * * *'), /minute: range "5-1" runs backwards/);
|
|
176
|
+
assert.match(why('*/0 * * * *'), /minute: step "0" must be a whole number of 1 or more/);
|
|
177
|
+
assert.match(why('0,,5 * * * *'), /minute: .* has an empty item/);
|
|
178
|
+
assert.match(why('1-2-3 * * * *'), /minute: "1-2-3" is not a range/);
|
|
179
|
+
assert.match(why('*/2/2 * * * *'), /minute: .* has more than one step/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('validateCadence: accepts what it should and explains what it will not', () => {
|
|
183
|
+
assert.deepEqual(validateCadence({ kind: 'daily', at: '07:30' }), { ok: true, cadence: { kind: 'daily', at: '07:30' } });
|
|
184
|
+
assert.deepEqual(validateCadence({ kind: 'weekly', day: 6, at: '23:59' }), { ok: true, cadence: { kind: 'weekly', day: 6, at: '23:59' } });
|
|
185
|
+
assert.deepEqual(validateCadence({ kind: 'interval', everyMinutes: 60 }), { ok: true, cadence: { kind: 'interval', everyMinutes: 60 } });
|
|
186
|
+
// Extra fields are dropped, and a cron expression comes back normalised.
|
|
187
|
+
assert.deepEqual(validateCadence({ kind: 'cron', expr: ' 0 9 * * 1-5 ', junk: 1 }), { ok: true, cadence: { kind: 'cron', expr: '0 9 * * 1-5' } });
|
|
188
|
+
|
|
189
|
+
const bad = (input: unknown) => {
|
|
190
|
+
const r = validateCadence(input);
|
|
191
|
+
assert.equal(r.ok, false, `expected ${JSON.stringify(input)} to be rejected`);
|
|
192
|
+
return (r as { ok: false; error: string }).error;
|
|
193
|
+
};
|
|
194
|
+
assert.match(bad(null), /must be an object/);
|
|
195
|
+
assert.match(bad('daily'), /must be an object/);
|
|
196
|
+
assert.match(bad([{ kind: 'daily', at: '07:30' }]), /must be an object/);
|
|
197
|
+
assert.match(bad({ kind: 'hourly' }), /kind must be daily, weekly, interval or cron/);
|
|
198
|
+
assert.match(bad({ kind: 'daily' }), /"HH:MM"/);
|
|
199
|
+
assert.match(bad({ kind: 'daily', at: '7:30' }), /"HH:MM"/);
|
|
200
|
+
assert.match(bad({ kind: 'daily', at: '24:00' }), /"HH:MM"/);
|
|
201
|
+
assert.match(bad({ kind: 'daily', at: '07:60' }), /"HH:MM"/);
|
|
202
|
+
assert.match(bad({ kind: 'weekly', day: 7, at: '07:30' }), /day must be a whole number 0/);
|
|
203
|
+
assert.match(bad({ kind: 'weekly', day: 1.5, at: '07:30' }), /day must be a whole number 0/);
|
|
204
|
+
assert.match(bad({ kind: 'weekly', at: '07:30' }), /day must be a whole number 0/);
|
|
205
|
+
assert.match(bad({ kind: 'interval', everyMinutes: 59 }), /at least 60/);
|
|
206
|
+
assert.match(bad({ kind: 'interval', everyMinutes: 90.5 }), /whole number of minutes/);
|
|
207
|
+
assert.match(bad({ kind: 'interval', everyMinutes: '90' }), /whole number of minutes/);
|
|
208
|
+
// The point of validating cron here: the reason travels to the 400.
|
|
209
|
+
assert.match(bad({ kind: 'cron', expr: '99 * * * *' }), /minute: "99" is out of range 0-59/);
|
|
210
|
+
assert.match(bad({ kind: 'cron', expr: ' ' }), /expr must be a cron expression/);
|
|
211
|
+
assert.match(bad({ kind: 'cron' }), /expr must be a cron expression/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('nextRuns previews N firings, strictly increasing', () => {
|
|
215
|
+
const three = nextRuns({ kind: 'daily', at: '07:30' }, local(2026, 5, 4, 9, 0), 3);
|
|
216
|
+
assert.deepEqual(three.map(show), ['2026-05-05 07:30', '2026-05-06 07:30', '2026-05-07 07:30']);
|
|
217
|
+
for (let i = 1; i < three.length; i++) assert.ok(three[i].getTime() > three[i - 1].getTime());
|
|
218
|
+
|
|
219
|
+
assert.deepEqual(
|
|
220
|
+
nextRuns({ kind: 'interval', everyMinutes: 90 }, local(2026, 5, 4, 9, 0), 3).map(show),
|
|
221
|
+
['2026-05-04 10:30', '2026-05-04 12:00', '2026-05-04 13:30'],
|
|
222
|
+
);
|
|
223
|
+
assert.deepEqual(
|
|
224
|
+
nextRuns({ kind: 'cron', expr: '0 9 * * 1-5' }, local(2026, 3, 6, 12, 0), 3).map(show),
|
|
225
|
+
['2026-03-09 09:00', '2026-03-10 09:00', '2026-03-11 09:00'],
|
|
226
|
+
);
|
|
227
|
+
// Nothing to preview: an impossible expression, and a zero count.
|
|
228
|
+
assert.deepEqual(nextRuns({ kind: 'cron', expr: '0 0 30 2 *' }, local(2026, 1, 1), 3), []);
|
|
229
|
+
assert.deepEqual(nextRuns({ kind: 'daily', at: '07:30' }, local(2026, 1, 1), 0), []);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('describeCadence puts the cadence in words', () => {
|
|
233
|
+
assert.equal(describeCadence({ kind: 'daily', at: '07:30' }), 'daily 07:30');
|
|
234
|
+
assert.equal(describeCadence({ kind: 'weekly', day: 1, at: '09:00' }), 'every Monday 09:00');
|
|
235
|
+
assert.equal(describeCadence({ kind: 'weekly', day: 0, at: '18:15' }), 'every Sunday 18:15');
|
|
236
|
+
assert.equal(describeCadence({ kind: 'interval', everyMinutes: 360 }), 'every 6 hours');
|
|
237
|
+
assert.equal(describeCadence({ kind: 'interval', everyMinutes: 60 }), 'every hour');
|
|
238
|
+
assert.equal(describeCadence({ kind: 'interval', everyMinutes: 90 }), 'every 90 minutes');
|
|
239
|
+
assert.equal(describeCadence({ kind: 'cron', expr: '0 9 * * 1-5' }), 'cron 0 9 * * 1-5');
|
|
240
|
+
});
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When does a schedule fire next.
|
|
3
|
+
*
|
|
4
|
+
* Two callers ask that question — the server's ticker, deciding whether a
|
|
5
|
+
* scheduled mission is due, and the preview route behind the UI's "next three
|
|
6
|
+
* runs". If they disagreed by so much as a minute the UI would be lying about
|
|
7
|
+
* something the user cannot otherwise see, so both go through this module and
|
|
8
|
+
* nothing else computes a firing time.
|
|
9
|
+
*
|
|
10
|
+
* Three deliberate positions:
|
|
11
|
+
* - No cron dependency. Five fields with `*`, lists, ranges and steps is an
|
|
12
|
+
* afternoon's work and a well-tested one; a package here would be a supply
|
|
13
|
+
* chain and an upgrade treadmill for a parser that never changes.
|
|
14
|
+
* - Everything is the machine's LOCAL time, computed with local Date fields.
|
|
15
|
+
* "daily 07:30" means what the person who typed it sees on the wall clock,
|
|
16
|
+
* which is not a fixed number of hours after midnight UTC.
|
|
17
|
+
* - Interval cadence is elapsed-time arithmetic instead, so "every 6 hours"
|
|
18
|
+
* stays every 6 hours through a DST change rather than gaining or losing
|
|
19
|
+
* one. Wall-clock cadences keep the wall-clock promise; that is the whole
|
|
20
|
+
* difference between them.
|
|
21
|
+
*
|
|
22
|
+
* Every search is capped (see MAX_SCAN_DAYS): an expression like `0 0 30 2 *`
|
|
23
|
+
* — February 30th — is legal to write and never happens, and a ticker must
|
|
24
|
+
* answer null for it rather than spin.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type Cadence =
|
|
28
|
+
| { kind: 'daily'; at: string } // 'HH:MM', machine local time
|
|
29
|
+
| { kind: 'weekly'; day: number; at: string } // day 0=Sunday..6=Saturday
|
|
30
|
+
| { kind: 'interval'; everyMinutes: number } // minimum 60
|
|
31
|
+
| { kind: 'cron'; expr: string }; // five fields: min hour dom mon dow
|
|
32
|
+
|
|
33
|
+
/** Parsed cron fields, each an ascending list of the values it matches. */
|
|
34
|
+
export interface CronFields {
|
|
35
|
+
minutes: number[];
|
|
36
|
+
hours: number[];
|
|
37
|
+
daysOfMonth: number[];
|
|
38
|
+
months: number[];
|
|
39
|
+
daysOfWeek: number[];
|
|
40
|
+
/** Was day-of-month something other than `*`? Decides the OR rule below. */
|
|
41
|
+
domRestricted: boolean;
|
|
42
|
+
/** Was day-of-week something other than `*`? */
|
|
43
|
+
dowRestricted: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 'HH:MM' on a 24-hour clock, and nothing else. */
|
|
47
|
+
const AT_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How far ahead a search will look before giving up. Five years is far past
|
|
51
|
+
* any schedule anyone would wait for, and it bounds the impossible ones
|
|
52
|
+
* (Feb 30, "the 31st of a 30-day month only") to a few thousand cheap checks.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_SCAN_DAYS = 366 * 5;
|
|
55
|
+
|
|
56
|
+
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
57
|
+
|
|
58
|
+
/** One cron field's name and legal range, for parsing and for error wording. */
|
|
59
|
+
interface FieldSpec { name: string; min: number; max: number }
|
|
60
|
+
|
|
61
|
+
const FIELD_SPECS: FieldSpec[] = [
|
|
62
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
63
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
64
|
+
{ name: 'day-of-month', min: 1, max: 31 },
|
|
65
|
+
{ name: 'month', min: 1, max: 12 },
|
|
66
|
+
// 7 is accepted below and folded onto 0; both spellings of Sunday are in use.
|
|
67
|
+
{ name: 'day-of-week', min: 0, max: 7 },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** A whole number, written plainly — no '1e3', no ' 5 ', no '+5'. */
|
|
71
|
+
function intOrNull(token: string): number | null {
|
|
72
|
+
if (!/^\d+$/.test(token)) return null;
|
|
73
|
+
const n = Number(token);
|
|
74
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One value in a field: in range or a named complaint. */
|
|
78
|
+
function valueIn(token: string, spec: FieldSpec): number {
|
|
79
|
+
const n = intOrNull(token);
|
|
80
|
+
if (n === null || n < spec.min || n > spec.max) {
|
|
81
|
+
throw new Error(`${spec.name}: ${JSON.stringify(token)} is out of range ${spec.min}-${spec.max}`);
|
|
82
|
+
}
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// One comma-separated term: `*`, `5`, `1-5`, `*/15`, `10-50/10`, `5/15`.
|
|
87
|
+
function parseTerm(term: string, spec: FieldSpec): number[] {
|
|
88
|
+
const [body, stepText, ...rest] = term.split('/');
|
|
89
|
+
if (rest.length) throw new Error(`${spec.name}: ${JSON.stringify(term)} has more than one step`);
|
|
90
|
+
|
|
91
|
+
let step = 1;
|
|
92
|
+
if (stepText !== undefined) {
|
|
93
|
+
const s = intOrNull(stepText);
|
|
94
|
+
if (s === null || s < 1) throw new Error(`${spec.name}: step ${JSON.stringify(stepText)} must be a whole number of 1 or more`);
|
|
95
|
+
step = s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let lo: number;
|
|
99
|
+
let hi: number;
|
|
100
|
+
if (body === '*') {
|
|
101
|
+
lo = spec.min;
|
|
102
|
+
hi = spec.max;
|
|
103
|
+
} else if (body.includes('-')) {
|
|
104
|
+
const [a, b, ...more] = body.split('-');
|
|
105
|
+
if (more.length) throw new Error(`${spec.name}: ${JSON.stringify(body)} is not a range`);
|
|
106
|
+
lo = valueIn(a, spec);
|
|
107
|
+
hi = valueIn(b, spec);
|
|
108
|
+
if (lo > hi) throw new Error(`${spec.name}: range ${JSON.stringify(body)} runs backwards`);
|
|
109
|
+
} else {
|
|
110
|
+
lo = valueIn(body, spec);
|
|
111
|
+
// `5/15` is Vixie cron's "from 5 to the end of the field, every 15" — a
|
|
112
|
+
// bare value with no step is just itself.
|
|
113
|
+
hi = stepText === undefined ? lo : spec.max;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const out: number[] = [];
|
|
117
|
+
for (let v = lo; v <= hi; v += step) out.push(v);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parses a five-field cron expression, or throws with a reason a person can
|
|
123
|
+
* act on. It throws rather than returning null because every caller either
|
|
124
|
+
* has already validated the expression or wants the reason: a bad expression
|
|
125
|
+
* that quietly became "never runs" is the failure mode worth designing out.
|
|
126
|
+
*/
|
|
127
|
+
export function parseCron(expr: string): CronFields {
|
|
128
|
+
const fields = String(expr ?? '').trim().split(/\s+/).filter(Boolean);
|
|
129
|
+
if (fields.length !== 5) {
|
|
130
|
+
throw new Error(`a cron expression has five fields (minute hour day-of-month month day-of-week), got ${fields.length}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parsed = fields.map((field, i) => {
|
|
134
|
+
const spec = FIELD_SPECS[i];
|
|
135
|
+
const values = new Set<number>();
|
|
136
|
+
for (const term of field.split(',')) {
|
|
137
|
+
if (!term) throw new Error(`${spec.name}: ${JSON.stringify(field)} has an empty item`);
|
|
138
|
+
// Sunday is 0 or 7; fold so membership tests only ever see 0.
|
|
139
|
+
for (const v of parseTerm(term, spec)) values.add(spec.name === 'day-of-week' && v === 7 ? 0 : v);
|
|
140
|
+
}
|
|
141
|
+
return [...values].sort((a, b) => a - b);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
minutes: parsed[0],
|
|
146
|
+
hours: parsed[1],
|
|
147
|
+
daysOfMonth: parsed[2],
|
|
148
|
+
months: parsed[3],
|
|
149
|
+
daysOfWeek: parsed[4],
|
|
150
|
+
domRestricted: fields[2] !== '*',
|
|
151
|
+
dowRestricted: fields[4] !== '*',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Does this local date match the cron day fields?
|
|
157
|
+
*
|
|
158
|
+
* The OR is not a shortcut. Standard cron treats day-of-month and day-of-week
|
|
159
|
+
* as alternatives whenever both are restricted, so `0 0 1 * 1` means "the 1st
|
|
160
|
+
* of the month AND every Monday", not "Mondays that fall on the 1st". People
|
|
161
|
+
* write `0 9 * * 1-5` and `0 9 1 * *` expecting exactly that, and a schedule
|
|
162
|
+
* that fired on neither would look broken.
|
|
163
|
+
*/
|
|
164
|
+
function dayMatches(d: Date, c: CronFields): boolean {
|
|
165
|
+
if (!c.months.includes(d.getMonth() + 1)) return false;
|
|
166
|
+
const dom = c.daysOfMonth.includes(d.getDate());
|
|
167
|
+
const dow = c.daysOfWeek.includes(d.getDay());
|
|
168
|
+
if (c.domRestricted && c.dowRestricted) return dom || dow;
|
|
169
|
+
return dom && dow;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The first local wall-clock instant strictly after `after` on a matching day
|
|
174
|
+
* at a matching hour and minute, or null within the scan cap.
|
|
175
|
+
*
|
|
176
|
+
* The candidate is built with local Date fields and taken as the Date
|
|
177
|
+
* constructor normalises it. On a spring-forward day 02:30 does not exist and
|
|
178
|
+
* normalises to 03:30 local — a real instant, a minute late, which is what a
|
|
179
|
+
* daily 02:30 schedule should do rather than skip the day or loop looking for
|
|
180
|
+
* a time the clock never shows.
|
|
181
|
+
*/
|
|
182
|
+
function nextWallClock(after: Date, c: CronFields): Date | null {
|
|
183
|
+
const cursor = new Date(after.getTime());
|
|
184
|
+
cursor.setSeconds(0, 0);
|
|
185
|
+
// Firings are strictly after `after`, and rounded to the minute: from
|
|
186
|
+
// 09:00:30 the next 09:00 candidate is tomorrow's, not this second's.
|
|
187
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
188
|
+
|
|
189
|
+
let day = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate());
|
|
190
|
+
for (let scanned = 0; scanned < MAX_SCAN_DAYS; scanned++) {
|
|
191
|
+
if (dayMatches(day, c)) {
|
|
192
|
+
const sameDay = day.getFullYear() === cursor.getFullYear()
|
|
193
|
+
&& day.getMonth() === cursor.getMonth()
|
|
194
|
+
&& day.getDate() === cursor.getDate();
|
|
195
|
+
for (const h of c.hours) {
|
|
196
|
+
if (sameDay && h < cursor.getHours()) continue;
|
|
197
|
+
for (const m of c.minutes) {
|
|
198
|
+
if (sameDay && h === cursor.getHours() && m < cursor.getMinutes()) continue;
|
|
199
|
+
const at = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, m, 0, 0);
|
|
200
|
+
if (at.getTime() > after.getTime()) return at;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Step by noon rather than midnight: adding 24 h to a midnight that a DST
|
|
205
|
+
// change moved can land back on the same date and stall the scan.
|
|
206
|
+
const nextDay = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 12, 0, 0, 0);
|
|
207
|
+
nextDay.setDate(nextDay.getDate() + 1);
|
|
208
|
+
day = new Date(nextDay.getFullYear(), nextDay.getMonth(), nextDay.getDate());
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The 'HH:MM' of a validated `at`, as numbers. */
|
|
214
|
+
function splitAt(at: string): { hour: number; minute: number } {
|
|
215
|
+
const [h, m] = at.split(':');
|
|
216
|
+
return { hour: Number(h), minute: Number(m) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The cron fields a daily/weekly cadence is shorthand for. */
|
|
220
|
+
function fieldsFor(cadence: Cadence): CronFields | null {
|
|
221
|
+
if (cadence.kind === 'cron') return parseCron(cadence.expr);
|
|
222
|
+
if (cadence.kind === 'daily') {
|
|
223
|
+
const { hour, minute } = splitAt(cadence.at);
|
|
224
|
+
return {
|
|
225
|
+
minutes: [minute], hours: [hour],
|
|
226
|
+
daysOfMonth: allBetween(1, 31), months: allBetween(1, 12), daysOfWeek: allBetween(0, 6),
|
|
227
|
+
domRestricted: false, dowRestricted: false,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (cadence.kind === 'weekly') {
|
|
231
|
+
const { hour, minute } = splitAt(cadence.at);
|
|
232
|
+
return {
|
|
233
|
+
minutes: [minute], hours: [hour],
|
|
234
|
+
daysOfMonth: allBetween(1, 31), months: allBetween(1, 12), daysOfWeek: [cadence.day],
|
|
235
|
+
domRestricted: false, dowRestricted: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function allBetween(lo: number, hi: number): number[] {
|
|
242
|
+
const out: number[] = [];
|
|
243
|
+
for (let v = lo; v <= hi; v++) out.push(v);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The first moment strictly after `after` at which this cadence fires, or
|
|
249
|
+
* null when it never will (an impossible cron expression, or an unparseable
|
|
250
|
+
* one that slipped past validation — a ticker must not throw).
|
|
251
|
+
*/
|
|
252
|
+
export function nextRunAt(cadence: Cadence, after: Date): Date | null {
|
|
253
|
+
if (!(after instanceof Date) || Number.isNaN(after.getTime())) return null;
|
|
254
|
+
if (cadence.kind === 'interval') {
|
|
255
|
+
if (!Number.isInteger(cadence.everyMinutes) || cadence.everyMinutes < 1) return null;
|
|
256
|
+
// Elapsed time, not wall clock: immune to DST by construction.
|
|
257
|
+
return new Date(after.getTime() + cadence.everyMinutes * 60_000);
|
|
258
|
+
}
|
|
259
|
+
let fields: CronFields | null;
|
|
260
|
+
try {
|
|
261
|
+
fields = fieldsFor(cadence);
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return fields ? nextWallClock(after, fields) : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The next `count` firings after `after` — the UI's "next three runs". */
|
|
269
|
+
export function nextRuns(cadence: Cadence, after: Date, count: number): Date[] {
|
|
270
|
+
const out: Date[] = [];
|
|
271
|
+
let cursor = after;
|
|
272
|
+
for (let i = 0; i < count; i++) {
|
|
273
|
+
const next = nextRunAt(cadence, cursor);
|
|
274
|
+
if (!next) break;
|
|
275
|
+
out.push(next);
|
|
276
|
+
cursor = next;
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Validates arbitrary JSON into a Cadence, or explains what is wrong. The
|
|
283
|
+
* server answers 400 with `error`; a bad cron expression must never become a
|
|
284
|
+
* schedule that silently never runs.
|
|
285
|
+
*/
|
|
286
|
+
export function validateCadence(input: unknown): { ok: true; cadence: Cadence } | { ok: false; error: string } {
|
|
287
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'cadence must be an object' };
|
|
288
|
+
const o = input as Record<string, unknown>;
|
|
289
|
+
const kind = o.kind;
|
|
290
|
+
|
|
291
|
+
if (kind === 'daily' || kind === 'weekly') {
|
|
292
|
+
if (typeof o.at !== 'string' || !AT_RE.test(o.at)) {
|
|
293
|
+
return { ok: false, error: `at must be a time of day as "HH:MM", got ${JSON.stringify(o.at)}` };
|
|
294
|
+
}
|
|
295
|
+
if (kind === 'daily') return { ok: true, cadence: { kind: 'daily', at: o.at } };
|
|
296
|
+
if (!Number.isInteger(o.day) || (o.day as number) < 0 || (o.day as number) > 6) {
|
|
297
|
+
return { ok: false, error: `day must be a whole number 0 (Sunday) to 6 (Saturday), got ${JSON.stringify(o.day)}` };
|
|
298
|
+
}
|
|
299
|
+
return { ok: true, cadence: { kind: 'weekly', day: o.day as number, at: o.at } };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (kind === 'interval') {
|
|
303
|
+
if (!Number.isInteger(o.everyMinutes)) {
|
|
304
|
+
return { ok: false, error: `everyMinutes must be a whole number of minutes, got ${JSON.stringify(o.everyMinutes)}` };
|
|
305
|
+
}
|
|
306
|
+
// An hour is the floor on purpose: anything tighter is a poll loop, not a
|
|
307
|
+
// schedule, and a mission takes longer than that to run anyway.
|
|
308
|
+
if ((o.everyMinutes as number) < 60) return { ok: false, error: 'everyMinutes must be at least 60' };
|
|
309
|
+
return { ok: true, cadence: { kind: 'interval', everyMinutes: o.everyMinutes as number } };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (kind === 'cron') {
|
|
313
|
+
if (typeof o.expr !== 'string' || !o.expr.trim()) return { ok: false, error: 'expr must be a cron expression' };
|
|
314
|
+
try {
|
|
315
|
+
parseCron(o.expr);
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return { ok: false, error: (e as Error).message };
|
|
318
|
+
}
|
|
319
|
+
return { ok: true, cadence: { kind: 'cron', expr: o.expr.trim().split(/\s+/).join(' ') } };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { ok: false, error: `kind must be daily, weekly, interval or cron, got ${JSON.stringify(kind)}` };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The cadence in words, for a run list or a confirmation line. */
|
|
326
|
+
export function describeCadence(cadence: Cadence): string {
|
|
327
|
+
switch (cadence.kind) {
|
|
328
|
+
case 'daily':
|
|
329
|
+
return `daily ${cadence.at}`;
|
|
330
|
+
case 'weekly':
|
|
331
|
+
return `every ${DAY_NAMES[cadence.day] ?? `day ${cadence.day}`} ${cadence.at}`;
|
|
332
|
+
case 'interval': {
|
|
333
|
+
const m = cadence.everyMinutes;
|
|
334
|
+
if (m % 60 === 0) {
|
|
335
|
+
const hours = m / 60;
|
|
336
|
+
return hours === 1 ? 'every hour' : `every ${hours} hours`;
|
|
337
|
+
}
|
|
338
|
+
return `every ${m} minutes`;
|
|
339
|
+
}
|
|
340
|
+
case 'cron':
|
|
341
|
+
return `cron ${cadence.expr}`;
|
|
342
|
+
}
|
|
343
|
+
}
|