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.
@@ -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
+ }
@@ -15,9 +15,11 @@ export class HttpServer {
15
15
  app;
16
16
  server;
17
17
  oracle;
18
- constructor(oracle) {
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
+ });