morpheus-cli 0.5.5 → 0.6.0
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 +44 -2
- package/dist/channels/telegram.js +264 -7
- package/dist/cli/commands/start.js +9 -1
- package/dist/config/manager.js +18 -1
- package/dist/config/schemas.js +6 -0
- package/dist/http/api.js +24 -2
- package/dist/http/routers/chronos.js +267 -0
- package/dist/http/server.js +4 -2
- package/dist/runtime/chronos/parser.js +215 -0
- package/dist/runtime/chronos/parser.test.js +63 -0
- package/dist/runtime/chronos/repository.js +244 -0
- package/dist/runtime/chronos/worker.js +141 -0
- package/dist/runtime/chronos/worker.test.js +120 -0
- package/dist/runtime/display.js +3 -0
- package/dist/runtime/memory/sqlite.js +10 -0
- package/dist/runtime/oracle.js +17 -4
- package/dist/runtime/tasks/dispatcher.js +8 -4
- package/dist/runtime/tasks/repository.js +17 -4
- package/dist/runtime/tools/chronos-tools.js +181 -0
- package/dist/runtime/tools/index.js +1 -0
- package/dist/ui/assets/index-BDWWF6gM.css +1 -0
- package/dist/ui/assets/index-DVQvTlPe.js +111 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +6 -1
- package/dist/ui/assets/index-DP2V4kRd.js +0 -112
- package/dist/ui/assets/index-mglRG5Zw.css +0 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ConfigManager } from '../../config/manager.js';
|
|
4
|
+
import { ChronosConfigSchema } from '../../config/schemas.js';
|
|
5
|
+
import { DisplayManager } from '../../runtime/display.js';
|
|
6
|
+
import { ChronosError } from '../../runtime/chronos/repository.js';
|
|
7
|
+
import { parseScheduleExpression, getNextOccurrences, } from '../../runtime/chronos/parser.js';
|
|
8
|
+
const ScheduleTypeSchema = z.enum(['once', 'cron', 'interval']);
|
|
9
|
+
const CreateJobSchema = z.object({
|
|
10
|
+
prompt: z.string().min(1).max(10000),
|
|
11
|
+
schedule_type: ScheduleTypeSchema,
|
|
12
|
+
schedule_expression: z.string().min(1),
|
|
13
|
+
timezone: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
const UpdateJobSchema = z.object({
|
|
16
|
+
prompt: z.string().min(1).max(10000).optional(),
|
|
17
|
+
schedule_expression: z.string().min(1).optional(),
|
|
18
|
+
timezone: z.string().optional(),
|
|
19
|
+
enabled: z.boolean().optional(),
|
|
20
|
+
});
|
|
21
|
+
const PreviewSchema = z.object({
|
|
22
|
+
expression: z.string().min(1),
|
|
23
|
+
schedule_type: ScheduleTypeSchema,
|
|
24
|
+
timezone: z.string().optional(),
|
|
25
|
+
});
|
|
26
|
+
const ExecutionsQuerySchema = z.object({
|
|
27
|
+
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
28
|
+
});
|
|
29
|
+
// ─── Job Router ───────────────────────────────────────────────────────────────
|
|
30
|
+
export function createChronosJobRouter(repo, _worker) {
|
|
31
|
+
const router = Router();
|
|
32
|
+
const configManager = ConfigManager.getInstance();
|
|
33
|
+
// POST /api/chronos/preview — must be before /:id routes
|
|
34
|
+
router.post('/preview', (req, res) => {
|
|
35
|
+
const parsed = PreviewSchema.safeParse(req.body);
|
|
36
|
+
if (!parsed.success) {
|
|
37
|
+
return res.status(400).json({ error: 'Invalid input', details: parsed.error.issues });
|
|
38
|
+
}
|
|
39
|
+
const { expression, schedule_type, timezone } = parsed.data;
|
|
40
|
+
const globalTz = configManager.getChronosConfig().timezone;
|
|
41
|
+
const opts = { timezone: timezone ?? globalTz };
|
|
42
|
+
try {
|
|
43
|
+
const result = parseScheduleExpression(expression, schedule_type, opts);
|
|
44
|
+
const next_occurrences = [];
|
|
45
|
+
if (result.cron_normalized) {
|
|
46
|
+
const timestamps = getNextOccurrences(result.cron_normalized, opts.timezone ?? 'UTC', 3);
|
|
47
|
+
for (const ts of timestamps) {
|
|
48
|
+
next_occurrences.push(new Date(ts).toLocaleString('en-US', {
|
|
49
|
+
timeZone: opts.timezone ?? 'UTC',
|
|
50
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
51
|
+
hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
res.json({
|
|
56
|
+
next_run_at: result.next_run_at,
|
|
57
|
+
human_readable: result.human_readable,
|
|
58
|
+
next_occurrences,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
res.status(400).json({ error: err.message });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// GET /api/chronos — list jobs
|
|
66
|
+
router.get('/', (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
const enabled = req.query.enabled;
|
|
69
|
+
const created_by = req.query.created_by;
|
|
70
|
+
const jobs = repo.listJobs({
|
|
71
|
+
enabled: enabled === 'true' ? true : enabled === 'false' ? false : undefined,
|
|
72
|
+
created_by,
|
|
73
|
+
});
|
|
74
|
+
res.json(jobs);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
res.status(500).json({ error: err.message });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
// POST /api/chronos — create job
|
|
81
|
+
router.post('/', (req, res) => {
|
|
82
|
+
const parsed = CreateJobSchema.safeParse(req.body);
|
|
83
|
+
if (!parsed.success) {
|
|
84
|
+
return res.status(400).json({ error: 'Invalid input', details: parsed.error.issues });
|
|
85
|
+
}
|
|
86
|
+
const { prompt, schedule_type, schedule_expression, timezone } = parsed.data;
|
|
87
|
+
const globalTz = configManager.getChronosConfig().timezone;
|
|
88
|
+
const tz = timezone ?? globalTz;
|
|
89
|
+
const opts = { timezone: tz };
|
|
90
|
+
try {
|
|
91
|
+
const schedule = parseScheduleExpression(schedule_expression, schedule_type, opts);
|
|
92
|
+
const job = repo.createJob({
|
|
93
|
+
prompt,
|
|
94
|
+
schedule_type,
|
|
95
|
+
schedule_expression,
|
|
96
|
+
cron_normalized: schedule.cron_normalized,
|
|
97
|
+
timezone: tz,
|
|
98
|
+
next_run_at: schedule.next_run_at,
|
|
99
|
+
created_by: 'ui',
|
|
100
|
+
});
|
|
101
|
+
const display = DisplayManager.getInstance();
|
|
102
|
+
display.log(`Job ${job.id} created — ${schedule.human_readable}`, { source: 'Chronos' });
|
|
103
|
+
res.status(201).json({
|
|
104
|
+
job,
|
|
105
|
+
human_readable: schedule.human_readable,
|
|
106
|
+
next_run_formatted: new Date(schedule.next_run_at).toLocaleString('en-US', {
|
|
107
|
+
timeZone: tz,
|
|
108
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
109
|
+
hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (err instanceof ChronosError) {
|
|
115
|
+
return res.status(429).json({ error: err.message });
|
|
116
|
+
}
|
|
117
|
+
res.status(400).json({ error: err.message });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// GET /api/chronos/:id
|
|
121
|
+
router.get('/:id', (req, res) => {
|
|
122
|
+
try {
|
|
123
|
+
const job = repo.getJob(req.params.id);
|
|
124
|
+
if (!job)
|
|
125
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
126
|
+
res.json(job);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
res.status(500).json({ error: err.message });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
// PUT /api/chronos/:id — update job
|
|
133
|
+
router.put('/:id', (req, res) => {
|
|
134
|
+
const parsed = UpdateJobSchema.safeParse(req.body);
|
|
135
|
+
if (!parsed.success) {
|
|
136
|
+
return res.status(400).json({ error: 'Invalid input', details: parsed.error.issues });
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const existing = repo.getJob(req.params.id);
|
|
140
|
+
if (!existing)
|
|
141
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
142
|
+
const patch = parsed.data;
|
|
143
|
+
const tz = patch.timezone ?? existing.timezone;
|
|
144
|
+
let updatedSchedule = undefined;
|
|
145
|
+
if (patch.schedule_expression) {
|
|
146
|
+
updatedSchedule = parseScheduleExpression(patch.schedule_expression, existing.schedule_type, {
|
|
147
|
+
timezone: tz,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const job = repo.updateJob(req.params.id, {
|
|
151
|
+
prompt: patch.prompt,
|
|
152
|
+
schedule_expression: patch.schedule_expression,
|
|
153
|
+
cron_normalized: updatedSchedule?.cron_normalized,
|
|
154
|
+
timezone: patch.timezone,
|
|
155
|
+
next_run_at: updatedSchedule?.next_run_at,
|
|
156
|
+
enabled: patch.enabled,
|
|
157
|
+
});
|
|
158
|
+
res.json(job);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
res.status(400).json({ error: err.message });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
// DELETE /api/chronos/:id
|
|
165
|
+
router.delete('/:id', (req, res) => {
|
|
166
|
+
try {
|
|
167
|
+
const deleted = repo.deleteJob(req.params.id);
|
|
168
|
+
if (!deleted)
|
|
169
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
170
|
+
res.json({ success: true, deleted_id: req.params.id });
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
res.status(500).json({ error: err.message });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// PATCH /api/chronos/:id/enable
|
|
177
|
+
router.patch('/:id/enable', (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const existing = repo.getJob(req.params.id);
|
|
180
|
+
if (!existing)
|
|
181
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
182
|
+
// Recompute next_run_at
|
|
183
|
+
let nextRunAt;
|
|
184
|
+
if (existing.cron_normalized) {
|
|
185
|
+
// cron_normalized is always a 5-field cron string regardless of the original schedule_type,
|
|
186
|
+
// so always parse it as 'cron' (not 'interval' or 'once').
|
|
187
|
+
const schedule = parseScheduleExpression(existing.cron_normalized, 'cron', {
|
|
188
|
+
timezone: existing.timezone,
|
|
189
|
+
});
|
|
190
|
+
nextRunAt = schedule.next_run_at;
|
|
191
|
+
}
|
|
192
|
+
else if (existing.schedule_type === 'once' && existing.next_run_at && existing.next_run_at > Date.now()) {
|
|
193
|
+
nextRunAt = existing.next_run_at;
|
|
194
|
+
}
|
|
195
|
+
repo.updateJob(req.params.id, { enabled: true, next_run_at: nextRunAt ?? undefined });
|
|
196
|
+
const job = repo.getJob(req.params.id);
|
|
197
|
+
res.json(job);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
res.status(400).json({ error: err.message });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
// PATCH /api/chronos/:id/disable
|
|
204
|
+
router.patch('/:id/disable', (req, res) => {
|
|
205
|
+
try {
|
|
206
|
+
const job = repo.disableJob(req.params.id);
|
|
207
|
+
if (!job)
|
|
208
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
209
|
+
res.json(job);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
res.status(500).json({ error: err.message });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
// GET /api/chronos/:id/executions
|
|
216
|
+
router.get('/:id/executions', (req, res) => {
|
|
217
|
+
try {
|
|
218
|
+
const query = ExecutionsQuerySchema.safeParse(req.query);
|
|
219
|
+
const limit = query.success ? query.data.limit : 50;
|
|
220
|
+
const job = repo.getJob(req.params.id);
|
|
221
|
+
if (!job)
|
|
222
|
+
return res.status(404).json({ error: 'Job not found' });
|
|
223
|
+
const executions = repo.listExecutions(req.params.id, limit);
|
|
224
|
+
res.json(executions);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
res.status(500).json({ error: err.message });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
return router;
|
|
231
|
+
}
|
|
232
|
+
// ─── Config Router ────────────────────────────────────────────────────────────
|
|
233
|
+
export function createChronosConfigRouter(worker) {
|
|
234
|
+
const router = Router();
|
|
235
|
+
const configManager = ConfigManager.getInstance();
|
|
236
|
+
// GET /api/config/chronos
|
|
237
|
+
router.get('/', (req, res) => {
|
|
238
|
+
try {
|
|
239
|
+
res.json(configManager.getChronosConfig());
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
res.status(500).json({ error: err.message });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
// POST /api/config/chronos
|
|
246
|
+
router.post('/', async (req, res) => {
|
|
247
|
+
const parsed = ChronosConfigSchema.partial().safeParse(req.body);
|
|
248
|
+
if (!parsed.success) {
|
|
249
|
+
return res.status(400).json({ error: 'Invalid input', details: parsed.error.issues });
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const current = configManager.get();
|
|
253
|
+
const newChronos = { ...configManager.getChronosConfig(), ...parsed.data };
|
|
254
|
+
await configManager.save({ ...current, chronos: newChronos });
|
|
255
|
+
if (parsed.data.check_interval_ms) {
|
|
256
|
+
worker.updateInterval(parsed.data.check_interval_ms);
|
|
257
|
+
}
|
|
258
|
+
const display = DisplayManager.getInstance();
|
|
259
|
+
display.log('Chronos configuration updated via UI', { source: 'Zaion', level: 'info' });
|
|
260
|
+
res.json(configManager.getChronosConfig());
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
res.status(500).json({ error: err.message });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
return router;
|
|
267
|
+
}
|
package/dist/http/server.js
CHANGED
|
@@ -15,9 +15,11 @@ export class HttpServer {
|
|
|
15
15
|
app;
|
|
16
16
|
server;
|
|
17
17
|
oracle;
|
|
18
|
-
|
|
18
|
+
chronosWorker;
|
|
19
|
+
constructor(oracle, chronosWorker) {
|
|
19
20
|
this.app = express();
|
|
20
21
|
this.oracle = oracle;
|
|
22
|
+
this.chronosWorker = chronosWorker;
|
|
21
23
|
// Wire Oracle into the webhook dispatcher so triggers use the full agent
|
|
22
24
|
WebhookDispatcher.setOracle(oracle);
|
|
23
25
|
this.setupMiddleware();
|
|
@@ -53,7 +55,7 @@ export class HttpServer {
|
|
|
53
55
|
// The trigger endpoint is public (validated via x-api-key header internally).
|
|
54
56
|
// All other webhook management endpoints apply authMiddleware internally.
|
|
55
57
|
this.app.use('/api/webhooks', createWebhooksRouter());
|
|
56
|
-
this.app.use('/api', authMiddleware, createApiRouter(this.oracle));
|
|
58
|
+
this.app.use('/api', authMiddleware, createApiRouter(this.oracle, this.chronosWorker));
|
|
57
59
|
// Serve static frontend from compiled output
|
|
58
60
|
const uiPath = path.resolve(__dirname, '../ui');
|
|
59
61
|
this.app.use(express.static(uiPath));
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import * as chrono from 'chrono-node';
|
|
2
|
+
import CronParser from 'cron-parser';
|
|
3
|
+
const parseCron = CronParser.parseExpression.bind(CronParser);
|
|
4
|
+
import cronstrue from 'cronstrue';
|
|
5
|
+
// Maps interval phrases like "every 30 minutes" to a cron expression.
|
|
6
|
+
function intervalToCron(expression) {
|
|
7
|
+
const lower = expression.toLowerCase().trim();
|
|
8
|
+
// ── Quantified intervals ──────────────────────────────────────────────────
|
|
9
|
+
// "every N minutes"
|
|
10
|
+
const minuteMatch = lower.match(/every\s+(\d+)\s+min(?:ute)?s?/);
|
|
11
|
+
if (minuteMatch)
|
|
12
|
+
return `*/${minuteMatch[1]} * * * *`;
|
|
13
|
+
// "every N hours"
|
|
14
|
+
const hourMatch = lower.match(/every\s+(\d+)\s+hours?/);
|
|
15
|
+
if (hourMatch)
|
|
16
|
+
return `0 */${hourMatch[1]} * * *`;
|
|
17
|
+
// "every N days"
|
|
18
|
+
const dayMatch = lower.match(/every\s+(\d+)\s+days?/);
|
|
19
|
+
if (dayMatch)
|
|
20
|
+
return `0 0 */${dayMatch[1]} * *`;
|
|
21
|
+
// "every N weeks" → approximate as every N*7 days
|
|
22
|
+
const weekNMatch = lower.match(/every\s+(\d+)\s+weeks?/);
|
|
23
|
+
if (weekNMatch)
|
|
24
|
+
return `0 0 */${Number(weekNMatch[1]) * 7} * *`;
|
|
25
|
+
// ── Single-unit shorthands ────────────────────────────────────────────────
|
|
26
|
+
if (/every\s+minute/.test(lower))
|
|
27
|
+
return `* * * * *`;
|
|
28
|
+
if (/every\s+hour/.test(lower))
|
|
29
|
+
return `0 * * * *`;
|
|
30
|
+
if (/every\s+day/.test(lower) || lower === 'daily')
|
|
31
|
+
return `0 0 * * *`;
|
|
32
|
+
if (/every\s+week(?!\w)/.test(lower) || lower === 'weekly')
|
|
33
|
+
return `0 0 * * 0`;
|
|
34
|
+
// ── Weekday / weekend ─────────────────────────────────────────────────────
|
|
35
|
+
if (/every\s+weekday/.test(lower))
|
|
36
|
+
return `0 0 * * 1-5`;
|
|
37
|
+
if (/every\s+weekend/.test(lower))
|
|
38
|
+
return `0 0 * * 0,6`;
|
|
39
|
+
// ── Named day(s)-of-week with optional "at HH[:MM] [am|pm]" ─────────────
|
|
40
|
+
// Handles single and multiple days:
|
|
41
|
+
// "every monday"
|
|
42
|
+
// "every monday and sunday at 9am"
|
|
43
|
+
// "every monday, wednesday and friday at 18:30"
|
|
44
|
+
const DOW = {
|
|
45
|
+
sunday: 0, sun: 0,
|
|
46
|
+
monday: 1, mon: 1,
|
|
47
|
+
tuesday: 2, tue: 2,
|
|
48
|
+
wednesday: 3, wed: 3,
|
|
49
|
+
thursday: 4, thu: 4,
|
|
50
|
+
friday: 5, fri: 5,
|
|
51
|
+
saturday: 6, sat: 6,
|
|
52
|
+
};
|
|
53
|
+
const DAY_NAMES = 'sunday|monday|tuesday|wednesday|thursday|friday|saturday|sun|mon|tue|wed|thu|fri|sat';
|
|
54
|
+
// Strip the leading "every " then capture the day-list and optional time tail
|
|
55
|
+
const multiDowRe = new RegExp(`^every\\s+((?:(?:${DAY_NAMES})(?:\\s*(?:,|\\band\\b)\\s*)?)*)(?:\\s+at\\s+(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm)?)?$`);
|
|
56
|
+
const multiDowMatch = lower.match(multiDowRe);
|
|
57
|
+
if (multiDowMatch) {
|
|
58
|
+
const dayListStr = multiDowMatch[1];
|
|
59
|
+
const foundDays = dayListStr.match(new RegExp(DAY_NAMES, 'g'));
|
|
60
|
+
if (foundDays && foundDays.length > 0) {
|
|
61
|
+
const dowValues = [...new Set(foundDays.map((d) => DOW[d]))].sort((a, b) => a - b);
|
|
62
|
+
let hour = 0;
|
|
63
|
+
let minute = 0;
|
|
64
|
+
if (multiDowMatch[2]) {
|
|
65
|
+
hour = parseInt(multiDowMatch[2], 10);
|
|
66
|
+
minute = multiDowMatch[3] ? parseInt(multiDowMatch[3], 10) : 0;
|
|
67
|
+
const period = multiDowMatch[4];
|
|
68
|
+
if (period === 'pm' && hour < 12)
|
|
69
|
+
hour += 12;
|
|
70
|
+
if (period === 'am' && hour === 12)
|
|
71
|
+
hour = 0;
|
|
72
|
+
}
|
|
73
|
+
return `${minute} ${hour} * * ${dowValues.join(',')}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Cannot parse interval expression: "${expression}". ` +
|
|
77
|
+
`Supported formats: "every N minutes/hours/days/weeks", "every minute/hour/day/week", ` +
|
|
78
|
+
`"every monday [at 9am]", "every monday and friday at 18:30", "every weekday", "every weekend", "daily", "weekly".`);
|
|
79
|
+
}
|
|
80
|
+
function formatDatetime(date, timezone) {
|
|
81
|
+
try {
|
|
82
|
+
return date.toLocaleString('en-US', {
|
|
83
|
+
timeZone: timezone,
|
|
84
|
+
year: 'numeric',
|
|
85
|
+
month: 'short',
|
|
86
|
+
day: 'numeric',
|
|
87
|
+
hour: '2-digit',
|
|
88
|
+
minute: '2-digit',
|
|
89
|
+
timeZoneName: 'short',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return date.toISOString();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function parseScheduleExpression(expression, type, opts = {}) {
|
|
97
|
+
const timezone = opts.timezone ?? 'UTC';
|
|
98
|
+
const refDate = opts.referenceDate ? new Date(opts.referenceDate) : new Date();
|
|
99
|
+
switch (type) {
|
|
100
|
+
case 'once': {
|
|
101
|
+
let parsed = null;
|
|
102
|
+
// 1. Relative duration: "in N minutes/hours/days/weeks" (handles abbreviations like "in 5 min")
|
|
103
|
+
const relMatch = expression.toLowerCase().trim().match(/^in\s+(\d+)\s+(min(?:ute)?s?|hours?|days?|weeks?)$/);
|
|
104
|
+
if (relMatch) {
|
|
105
|
+
const amount = parseInt(relMatch[1], 10);
|
|
106
|
+
const unit = relMatch[2];
|
|
107
|
+
const ms = unit.startsWith('min') ? amount * 60_000
|
|
108
|
+
: unit.startsWith('hour') ? amount * 3_600_000
|
|
109
|
+
: unit.startsWith('day') ? amount * 86_400_000
|
|
110
|
+
: amount * 7 * 86_400_000;
|
|
111
|
+
parsed = new Date(refDate.getTime() + ms);
|
|
112
|
+
}
|
|
113
|
+
// 2. ISO 8601
|
|
114
|
+
if (!parsed) {
|
|
115
|
+
const isoDate = new Date(expression);
|
|
116
|
+
if (!isNaN(isoDate.getTime()))
|
|
117
|
+
parsed = isoDate;
|
|
118
|
+
}
|
|
119
|
+
// 3. chrono-node NLP fallback ("tomorrow at 9am", "next friday", etc.)
|
|
120
|
+
if (!parsed) {
|
|
121
|
+
const results = chrono.parse(expression, { instant: refDate, timezone });
|
|
122
|
+
if (results.length > 0 && results[0].date()) {
|
|
123
|
+
parsed = results[0].date();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!parsed) {
|
|
127
|
+
throw new Error(`Could not parse date/time expression: "${expression}". ` +
|
|
128
|
+
`Try: "in 30 minutes", "in 2 hours", "tomorrow at 9am", "next friday at 3pm", or an ISO 8601 datetime.`);
|
|
129
|
+
}
|
|
130
|
+
if (parsed.getTime() <= refDate.getTime()) {
|
|
131
|
+
throw new Error(`Scheduled time must be in the future. Got: "${expression}" which resolves to ${parsed.toISOString()}.`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
type: 'once',
|
|
135
|
+
next_run_at: parsed.getTime(),
|
|
136
|
+
cron_normalized: null,
|
|
137
|
+
human_readable: formatDatetime(parsed, timezone),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
case 'cron': {
|
|
141
|
+
let interval;
|
|
142
|
+
try {
|
|
143
|
+
interval = parseCron(expression, { tz: timezone, currentDate: refDate });
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw new Error(`Invalid cron expression: "${expression}". ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
// Enforce minimum 60s interval by checking two consecutive occurrences
|
|
149
|
+
const first = interval.next().toDate();
|
|
150
|
+
const second = interval.next().toDate();
|
|
151
|
+
const intervalMs = second.getTime() - first.getTime();
|
|
152
|
+
if (intervalMs < 60000) {
|
|
153
|
+
throw new Error(`Minimum interval is 60 seconds. The cron expression "${expression}" triggers more frequently.`);
|
|
154
|
+
}
|
|
155
|
+
// Recompute for next_run_at (cron-parser iterator was advanced above)
|
|
156
|
+
const nextInterval = parseCron(expression, { tz: timezone, currentDate: refDate });
|
|
157
|
+
const next = nextInterval.next().toDate();
|
|
158
|
+
let human_readable;
|
|
159
|
+
try {
|
|
160
|
+
human_readable = cronstrue.toString(expression, { throwExceptionOnParseError: true });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
human_readable = expression;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
type: 'cron',
|
|
167
|
+
next_run_at: next.getTime(),
|
|
168
|
+
cron_normalized: expression,
|
|
169
|
+
human_readable,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case 'interval': {
|
|
173
|
+
const cronExpr = intervalToCron(expression);
|
|
174
|
+
// Validate via cron case (will also enforce minimum 60s)
|
|
175
|
+
const result = parseScheduleExpression(cronExpr, 'cron', opts);
|
|
176
|
+
let human_readable;
|
|
177
|
+
try {
|
|
178
|
+
human_readable = cronstrue.toString(cronExpr, { throwExceptionOnParseError: true });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
human_readable = expression;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
type: 'interval',
|
|
185
|
+
next_run_at: result.next_run_at,
|
|
186
|
+
cron_normalized: cronExpr,
|
|
187
|
+
human_readable,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
throw new Error(`Unknown schedule type: "${type}"`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Compute the next occurrence for a recurring job after execution.
|
|
196
|
+
* Used by ChronosWorker after each successful trigger.
|
|
197
|
+
*/
|
|
198
|
+
export function parseNextRun(cronNormalized, timezone, referenceDate) {
|
|
199
|
+
const refDate = referenceDate ? new Date(referenceDate) : new Date();
|
|
200
|
+
const interval = parseCron(cronNormalized, { tz: timezone, currentDate: refDate });
|
|
201
|
+
return interval.next().toDate().getTime();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Compute the next N occurrences for a recurring schedule.
|
|
205
|
+
* Used by the preview endpoint.
|
|
206
|
+
*/
|
|
207
|
+
export function getNextOccurrences(cronNormalized, timezone, count = 3, referenceDate) {
|
|
208
|
+
const refDate = referenceDate ? new Date(referenceDate) : new Date();
|
|
209
|
+
const interval = parseCron(cronNormalized, { tz: timezone, currentDate: refDate });
|
|
210
|
+
const results = [];
|
|
211
|
+
for (let i = 0; i < count; i++) {
|
|
212
|
+
results.push(interval.next().toDate().getTime());
|
|
213
|
+
}
|
|
214
|
+
return results;
|
|
215
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseScheduleExpression } from './parser.js';
|
|
3
|
+
const FUTURE_MS = Date.now() + 60_000 * 60 * 24; // 24 hours from now
|
|
4
|
+
const REF = Date.now();
|
|
5
|
+
describe('parseScheduleExpression — once type', () => {
|
|
6
|
+
it('parses a valid ISO datetime in the future', () => {
|
|
7
|
+
const future = new Date(FUTURE_MS).toISOString();
|
|
8
|
+
const result = parseScheduleExpression(future, 'once', { referenceDate: REF });
|
|
9
|
+
expect(result.type).toBe('once');
|
|
10
|
+
expect(result.next_run_at).toBeGreaterThan(REF);
|
|
11
|
+
expect(result.cron_normalized).toBeNull();
|
|
12
|
+
expect(result.human_readable).toBeTruthy();
|
|
13
|
+
});
|
|
14
|
+
it('throws for a past datetime', () => {
|
|
15
|
+
const past = new Date(Date.now() - 1000).toISOString();
|
|
16
|
+
expect(() => parseScheduleExpression(past, 'once', { referenceDate: REF })).toThrow(/must be in the future/i);
|
|
17
|
+
});
|
|
18
|
+
it('parses natural language "tomorrow at 9am" in a given timezone', () => {
|
|
19
|
+
const result = parseScheduleExpression('tomorrow at 9am', 'once', {
|
|
20
|
+
timezone: 'America/Sao_Paulo',
|
|
21
|
+
referenceDate: REF,
|
|
22
|
+
});
|
|
23
|
+
expect(result.type).toBe('once');
|
|
24
|
+
expect(result.next_run_at).toBeGreaterThan(REF);
|
|
25
|
+
expect(result.cron_normalized).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('parseScheduleExpression — cron type', () => {
|
|
29
|
+
it('parses a valid 5-field cron expression', () => {
|
|
30
|
+
const result = parseScheduleExpression('0 9 * * 1-5', 'cron', { referenceDate: REF });
|
|
31
|
+
expect(result.type).toBe('cron');
|
|
32
|
+
expect(result.next_run_at).toBeGreaterThan(REF);
|
|
33
|
+
expect(result.cron_normalized).toBe('0 9 * * 1-5');
|
|
34
|
+
expect(result.human_readable.length).toBeGreaterThan(0);
|
|
35
|
+
});
|
|
36
|
+
it('throws for an invalid cron expression', () => {
|
|
37
|
+
expect(() => parseScheduleExpression('not a cron', 'cron', { referenceDate: REF })).toThrow(/invalid cron/i);
|
|
38
|
+
});
|
|
39
|
+
it('throws when cron interval is less than 60 seconds (every minute)', () => {
|
|
40
|
+
// "* * * * *" fires every 60s — exactly at the boundary. Accept it.
|
|
41
|
+
// "*/30 * * * * *" (6-field sub-minute) would fail but cron-parser v4 uses 5-field only.
|
|
42
|
+
// We test a cron that would trigger at sub-minute intervals if possible.
|
|
43
|
+
// For 5-field cron the minimum is 60s — "* * * * *" is exactly 60s so it's valid.
|
|
44
|
+
const result = parseScheduleExpression('* * * * *', 'cron', { referenceDate: REF });
|
|
45
|
+
expect(result.next_run_at).toBeGreaterThan(REF);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('parseScheduleExpression — interval type', () => {
|
|
49
|
+
it('converts "every 30 minutes" to a valid cron with interval >= 60s', () => {
|
|
50
|
+
const result = parseScheduleExpression('every 30 minutes', 'interval', { referenceDate: REF });
|
|
51
|
+
expect(result.type).toBe('interval');
|
|
52
|
+
expect(result.next_run_at).toBeGreaterThan(REF);
|
|
53
|
+
expect(result.cron_normalized).toBe('*/30 * * * *');
|
|
54
|
+
expect(result.human_readable.length).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
it('converts "every hour" to a valid cron', () => {
|
|
57
|
+
const result = parseScheduleExpression('every hour', 'interval', { referenceDate: REF });
|
|
58
|
+
expect(result.cron_normalized).toBe('0 * * * *');
|
|
59
|
+
});
|
|
60
|
+
it('throws for an unsupported interval phrase', () => {
|
|
61
|
+
expect(() => parseScheduleExpression('every 30 seconds', 'interval', { referenceDate: REF })).toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|