@karmaniverous/jeeves-runner 0.4.1 → 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/dist/cli/jeeves-runner/index.js +545 -82
- package/dist/index.d.ts +65 -4
- package/dist/mjs/index.js +548 -79
- package/package.json +5 -3
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync, existsSync, readFileSync
|
|
3
|
-
import { dirname, extname, resolve } from 'node:path';
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, extname, resolve } from 'node:path';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
-
import { Cron, CronPattern } from 'croner';
|
|
6
5
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
6
|
import { pino } from 'pino';
|
|
8
7
|
import Fastify from 'fastify';
|
|
8
|
+
import { createConfigQueryHandler } from '@karmaniverous/jeeves';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { RRStack } from '@karmaniverous/rrstack';
|
|
11
|
+
import { Cron } from 'croner';
|
|
12
|
+
import { JSONPath } from 'jsonpath-plus';
|
|
9
13
|
import { request as request$1 } from 'node:http';
|
|
10
14
|
import { request } from 'node:https';
|
|
11
15
|
import { spawn } from 'node:child_process';
|
|
12
|
-
import {
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* SQLite connection manager. Creates DB file with parent directories, enables WAL mode for concurrency.
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
16
22
|
*/
|
|
17
23
|
/**
|
|
18
24
|
* Create and configure a SQLite database connection.
|
|
@@ -41,6 +47,8 @@ function closeConnection(db) {
|
|
|
41
47
|
|
|
42
48
|
/**
|
|
43
49
|
* Schema migration runner. Tracks applied migrations via schema_version table, applies pending migrations idempotently.
|
|
50
|
+
*
|
|
51
|
+
* @module
|
|
44
52
|
*/
|
|
45
53
|
/** Initial schema migration SQL (embedded to avoid runtime file resolution issues). */
|
|
46
54
|
const MIGRATION_001 = `
|
|
@@ -174,11 +182,16 @@ CREATE TABLE state_items (
|
|
|
174
182
|
);
|
|
175
183
|
CREATE INDEX idx_state_items_ns_key ON state_items(namespace, key);
|
|
176
184
|
`;
|
|
185
|
+
/** Migration 004: Add source_type column to jobs. */
|
|
186
|
+
const MIGRATION_004 = `
|
|
187
|
+
ALTER TABLE jobs ADD COLUMN source_type TEXT DEFAULT 'path';
|
|
188
|
+
`;
|
|
177
189
|
/** Registry of all migrations keyed by version number. */
|
|
178
190
|
const MIGRATIONS = {
|
|
179
191
|
1: MIGRATION_001,
|
|
180
192
|
2: MIGRATION_002,
|
|
181
193
|
3: MIGRATION_003,
|
|
194
|
+
4: MIGRATION_004,
|
|
182
195
|
};
|
|
183
196
|
/**
|
|
184
197
|
* Run all pending migrations. Creates schema_version table if needed, applies migrations in order.
|
|
@@ -208,13 +221,394 @@ function runMigrations(db) {
|
|
|
208
221
|
}
|
|
209
222
|
|
|
210
223
|
/**
|
|
211
|
-
*
|
|
224
|
+
* Schedule parsing, validation, and next-fire-time computation for cron and RRStack formats.
|
|
225
|
+
*
|
|
226
|
+
* @module
|
|
227
|
+
*/
|
|
228
|
+
/**
|
|
229
|
+
* Attempt to parse a schedule string as RRStack JSON (non-null, non-array
|
|
230
|
+
* object). Returns the parsed options on success, or null if the string
|
|
231
|
+
* is not a JSON object.
|
|
232
|
+
*/
|
|
233
|
+
function tryParseRRStack(schedule) {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(schedule);
|
|
236
|
+
if (typeof parsed === 'object' &&
|
|
237
|
+
parsed !== null &&
|
|
238
|
+
!Array.isArray(parsed)) {
|
|
239
|
+
return parsed;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Compute the next fire time for a schedule string.
|
|
249
|
+
* Supports cron expressions (via croner) and RRStack JSON (via nextEvent).
|
|
250
|
+
*
|
|
251
|
+
* @param schedule - Cron expression or RRStack JSON string.
|
|
252
|
+
* @returns Next fire time as a Date, or null if none can be determined.
|
|
253
|
+
*/
|
|
254
|
+
function getNextFireTime(schedule) {
|
|
255
|
+
const rrOpts = tryParseRRStack(schedule);
|
|
256
|
+
if (rrOpts) {
|
|
257
|
+
const stack = new RRStack(rrOpts);
|
|
258
|
+
const next = stack.nextEvent();
|
|
259
|
+
if (!next)
|
|
260
|
+
return null;
|
|
261
|
+
const unit = stack.timeUnit;
|
|
262
|
+
return new Date(unit === 's' ? next.at * 1000 : next.at);
|
|
263
|
+
}
|
|
264
|
+
const cron = new Cron(schedule);
|
|
265
|
+
return cron.nextRun() ?? null;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Validate a schedule string as either a cron expression or RRStack JSON.
|
|
269
|
+
*
|
|
270
|
+
* @param schedule - Cron expression or RRStack JSON string.
|
|
271
|
+
* @returns Validation result with format on success, or error message on failure.
|
|
272
|
+
*/
|
|
273
|
+
function validateSchedule(schedule) {
|
|
274
|
+
const rrOpts = tryParseRRStack(schedule);
|
|
275
|
+
if (rrOpts) {
|
|
276
|
+
try {
|
|
277
|
+
new RRStack(rrOpts);
|
|
278
|
+
return { valid: true, format: 'rrstack' };
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
return {
|
|
282
|
+
valid: false,
|
|
283
|
+
error: `Invalid RRStack schedule: ${err instanceof Error ? err.message : String(err)}`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
new Cron(schedule);
|
|
289
|
+
return { valid: true, format: 'cron' };
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return {
|
|
293
|
+
valid: false,
|
|
294
|
+
error: `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Job CRUD routes: create, update, delete, enable/disable, and script update endpoints.
|
|
301
|
+
*
|
|
302
|
+
* @module
|
|
303
|
+
*/
|
|
304
|
+
/** Zod schema for job creation/update request body. */
|
|
305
|
+
const createJobSchema = z.object({
|
|
306
|
+
id: z.string().min(1),
|
|
307
|
+
name: z.string().min(1),
|
|
308
|
+
schedule: z.string().min(1),
|
|
309
|
+
script: z.string().min(1),
|
|
310
|
+
source_type: z.enum(['path', 'inline']).default('path'),
|
|
311
|
+
type: z.enum(['script', 'session']).default('script'),
|
|
312
|
+
timeout_seconds: z.number().positive().optional(),
|
|
313
|
+
overlap_policy: z.enum(['skip', 'allow']).default('skip'),
|
|
314
|
+
enabled: z.boolean().default(true),
|
|
315
|
+
description: z.string().optional(),
|
|
316
|
+
on_failure: z.string().nullable().optional(),
|
|
317
|
+
on_success: z.string().nullable().optional(),
|
|
318
|
+
});
|
|
319
|
+
/** Zod schema for job update (all fields optional except what's set). */
|
|
320
|
+
const updateJobSchema = createJobSchema.omit({ id: true }).partial();
|
|
321
|
+
/** Zod schema for script update request body. */
|
|
322
|
+
const updateScriptSchema = z.object({
|
|
323
|
+
script: z.string().min(1),
|
|
324
|
+
source_type: z.enum(['path', 'inline']).optional(),
|
|
325
|
+
});
|
|
326
|
+
/** Standard error message for missing job resources. */
|
|
327
|
+
const JOB_NOT_FOUND = 'Job not found';
|
|
328
|
+
/**
|
|
329
|
+
* Register job CRUD routes on the Fastify instance.
|
|
330
|
+
*/
|
|
331
|
+
function registerJobRoutes(app, deps) {
|
|
332
|
+
const { db, scheduler } = deps;
|
|
333
|
+
/** POST /jobs — Create a new job. */
|
|
334
|
+
app.post('/jobs', (request, reply) => {
|
|
335
|
+
const parsed = createJobSchema.safeParse(request.body);
|
|
336
|
+
if (!parsed.success) {
|
|
337
|
+
reply.code(400);
|
|
338
|
+
return { error: parsed.error.message };
|
|
339
|
+
}
|
|
340
|
+
const data = parsed.data;
|
|
341
|
+
const validation = validateSchedule(data.schedule);
|
|
342
|
+
if (!validation.valid) {
|
|
343
|
+
reply.code(400);
|
|
344
|
+
return { error: validation.error };
|
|
345
|
+
}
|
|
346
|
+
const timeoutMs = data.timeout_seconds ? data.timeout_seconds * 1000 : null;
|
|
347
|
+
db.prepare(`INSERT INTO jobs (id, name, schedule, script, source_type, type, timeout_ms,
|
|
348
|
+
overlap_policy, enabled, description, on_failure, on_success)
|
|
349
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(data.id, data.name, data.schedule, data.script, data.source_type, data.type, timeoutMs, data.overlap_policy, data.enabled ? 1 : 0, data.description ?? null, data.on_failure ?? null, data.on_success ?? null);
|
|
350
|
+
scheduler.reconcileNow();
|
|
351
|
+
reply.code(201);
|
|
352
|
+
return { ok: true, id: data.id };
|
|
353
|
+
});
|
|
354
|
+
/** PATCH /jobs/:id — Partial update of an existing job. */
|
|
355
|
+
app.patch('/jobs/:id', (request, reply) => {
|
|
356
|
+
const parsed = updateJobSchema.safeParse(request.body);
|
|
357
|
+
if (!parsed.success) {
|
|
358
|
+
reply.code(400);
|
|
359
|
+
return { error: parsed.error.message };
|
|
360
|
+
}
|
|
361
|
+
const existing = db
|
|
362
|
+
.prepare('SELECT id FROM jobs WHERE id = ?')
|
|
363
|
+
.get(request.params.id);
|
|
364
|
+
if (!existing) {
|
|
365
|
+
reply.code(404);
|
|
366
|
+
return { error: JOB_NOT_FOUND };
|
|
367
|
+
}
|
|
368
|
+
const data = parsed.data;
|
|
369
|
+
if (data.schedule) {
|
|
370
|
+
const validation = validateSchedule(data.schedule);
|
|
371
|
+
if (!validation.valid) {
|
|
372
|
+
reply.code(400);
|
|
373
|
+
return { error: validation.error };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** Map input field → DB column + value transform. */
|
|
377
|
+
const fieldMap = [
|
|
378
|
+
{ input: 'name', column: 'name' },
|
|
379
|
+
{ input: 'schedule', column: 'schedule' },
|
|
380
|
+
{ input: 'script', column: 'script' },
|
|
381
|
+
{ input: 'source_type', column: 'source_type' },
|
|
382
|
+
{ input: 'type', column: 'type' },
|
|
383
|
+
{
|
|
384
|
+
input: 'timeout_seconds',
|
|
385
|
+
column: 'timeout_ms',
|
|
386
|
+
transform: (v) => v * 1000,
|
|
387
|
+
},
|
|
388
|
+
{ input: 'overlap_policy', column: 'overlap_policy' },
|
|
389
|
+
{
|
|
390
|
+
input: 'enabled',
|
|
391
|
+
column: 'enabled',
|
|
392
|
+
transform: (v) => (v ? 1 : 0),
|
|
393
|
+
},
|
|
394
|
+
{ input: 'description', column: 'description' },
|
|
395
|
+
{ input: 'on_failure', column: 'on_failure' },
|
|
396
|
+
{ input: 'on_success', column: 'on_success' },
|
|
397
|
+
];
|
|
398
|
+
const sets = [];
|
|
399
|
+
const values = [];
|
|
400
|
+
for (const { input, column, transform } of fieldMap) {
|
|
401
|
+
if (data[input] !== undefined) {
|
|
402
|
+
sets.push(`${column} = ?`);
|
|
403
|
+
values.push(transform ? transform(data[input]) : data[input]);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (sets.length > 0) {
|
|
407
|
+
sets.push("updated_at = datetime('now')");
|
|
408
|
+
values.push(request.params.id);
|
|
409
|
+
db.prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
|
410
|
+
}
|
|
411
|
+
scheduler.reconcileNow();
|
|
412
|
+
return { ok: true };
|
|
413
|
+
});
|
|
414
|
+
/** DELETE /jobs/:id — Delete a job and its runs. */
|
|
415
|
+
app.delete('/jobs/:id', (request, reply) => {
|
|
416
|
+
db.prepare('DELETE FROM runs WHERE job_id = ?').run(request.params.id);
|
|
417
|
+
const result = db
|
|
418
|
+
.prepare('DELETE FROM jobs WHERE id = ?')
|
|
419
|
+
.run(request.params.id);
|
|
420
|
+
if (result.changes === 0) {
|
|
421
|
+
reply.code(404);
|
|
422
|
+
return { error: JOB_NOT_FOUND };
|
|
423
|
+
}
|
|
424
|
+
scheduler.reconcileNow();
|
|
425
|
+
return { ok: true };
|
|
426
|
+
});
|
|
427
|
+
/** Register a PATCH toggle endpoint (enable or disable). */
|
|
428
|
+
function registerToggle(path, enabledValue) {
|
|
429
|
+
app.patch(path, (request, reply) => {
|
|
430
|
+
const result = db
|
|
431
|
+
.prepare('UPDATE jobs SET enabled = ? WHERE id = ?')
|
|
432
|
+
.run(enabledValue, request.params.id);
|
|
433
|
+
if (result.changes === 0) {
|
|
434
|
+
reply.code(404);
|
|
435
|
+
return { error: JOB_NOT_FOUND };
|
|
436
|
+
}
|
|
437
|
+
scheduler.reconcileNow();
|
|
438
|
+
return { ok: true };
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
registerToggle('/jobs/:id/enable', 1);
|
|
442
|
+
registerToggle('/jobs/:id/disable', 0);
|
|
443
|
+
/** PUT /jobs/:id/script — Update job script and source_type. */
|
|
444
|
+
app.put('/jobs/:id/script', (request, reply) => {
|
|
445
|
+
const parsed = updateScriptSchema.safeParse(request.body);
|
|
446
|
+
if (!parsed.success) {
|
|
447
|
+
reply.code(400);
|
|
448
|
+
return { error: parsed.error.message };
|
|
449
|
+
}
|
|
450
|
+
const data = parsed.data;
|
|
451
|
+
const result = data.source_type
|
|
452
|
+
? db
|
|
453
|
+
.prepare('UPDATE jobs SET script = ?, source_type = ? WHERE id = ?')
|
|
454
|
+
.run(data.script, data.source_type, request.params.id)
|
|
455
|
+
: db
|
|
456
|
+
.prepare('UPDATE jobs SET script = ? WHERE id = ?')
|
|
457
|
+
.run(data.script, request.params.id);
|
|
458
|
+
if (result.changes === 0) {
|
|
459
|
+
reply.code(404);
|
|
460
|
+
return { error: JOB_NOT_FOUND };
|
|
461
|
+
}
|
|
462
|
+
scheduler.reconcileNow();
|
|
463
|
+
return { ok: true };
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Queue inspection routes: list queues, queue status, and peek at pending items.
|
|
469
|
+
*
|
|
470
|
+
* @module
|
|
471
|
+
*/
|
|
472
|
+
/**
|
|
473
|
+
* Register queue inspection routes on the Fastify instance.
|
|
474
|
+
*/
|
|
475
|
+
function registerQueueRoutes(app, deps) {
|
|
476
|
+
const { db } = deps;
|
|
477
|
+
/** GET /queues — List all distinct queues that have items. */
|
|
478
|
+
app.get('/queues', () => {
|
|
479
|
+
const rows = db
|
|
480
|
+
.prepare('SELECT DISTINCT queue_id FROM queue_items')
|
|
481
|
+
.all();
|
|
482
|
+
return { queues: rows.map((r) => r.queue_id) };
|
|
483
|
+
});
|
|
484
|
+
/** GET /queues/:name/status — Queue depth, claimed count, failed count, oldest age. */
|
|
485
|
+
app.get('/queues/:name/status', (request) => {
|
|
486
|
+
const row = db
|
|
487
|
+
.prepare(`SELECT
|
|
488
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS depth,
|
|
489
|
+
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) AS claimed,
|
|
490
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
|
|
491
|
+
MIN(CASE WHEN status = 'pending' THEN created_at END) AS oldest
|
|
492
|
+
FROM queue_items
|
|
493
|
+
WHERE queue_id = ?`)
|
|
494
|
+
.get(request.params.name);
|
|
495
|
+
return {
|
|
496
|
+
depth: row.depth ?? 0,
|
|
497
|
+
claimedCount: row.claimed ?? 0,
|
|
498
|
+
failedCount: row.failed ?? 0,
|
|
499
|
+
oldestAge: row.oldest
|
|
500
|
+
? Date.now() - new Date(row.oldest).getTime()
|
|
501
|
+
: null,
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
/** GET /queues/:name/peek — Non-claiming read of pending items. */
|
|
505
|
+
app.get('/queues/:name/peek', (request) => {
|
|
506
|
+
const name = request.params.name;
|
|
507
|
+
const limit = parseInt(request.query.limit ?? '10', 10);
|
|
508
|
+
const items = db
|
|
509
|
+
.prepare(`SELECT id, payload, priority, created_at FROM queue_items
|
|
510
|
+
WHERE queue_id = ? AND status = 'pending'
|
|
511
|
+
ORDER BY priority DESC, created_at
|
|
512
|
+
LIMIT ?`)
|
|
513
|
+
.all(name, limit);
|
|
514
|
+
return {
|
|
515
|
+
items: items.map((item) => ({
|
|
516
|
+
id: item.id,
|
|
517
|
+
payload: JSON.parse(item.payload),
|
|
518
|
+
priority: item.priority,
|
|
519
|
+
createdAt: item.created_at,
|
|
520
|
+
})),
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* State inspection routes: list namespaces, read scalar state, and read collection items.
|
|
527
|
+
*
|
|
528
|
+
* @module
|
|
529
|
+
*/
|
|
530
|
+
/**
|
|
531
|
+
* Register state inspection routes on the Fastify instance.
|
|
532
|
+
*/
|
|
533
|
+
function registerStateRoutes(app, deps) {
|
|
534
|
+
const { db } = deps;
|
|
535
|
+
/** GET /state — List all distinct namespaces. */
|
|
536
|
+
app.get('/state', () => {
|
|
537
|
+
const rows = db
|
|
538
|
+
.prepare('SELECT DISTINCT namespace FROM state')
|
|
539
|
+
.all();
|
|
540
|
+
return { namespaces: rows.map((r) => r.namespace) };
|
|
541
|
+
});
|
|
542
|
+
/** GET /state/:namespace — Materialise all scalar state as key-value map. */
|
|
543
|
+
app.get('/state/:namespace', (request) => {
|
|
544
|
+
const { namespace } = request.params;
|
|
545
|
+
const rows = db
|
|
546
|
+
.prepare(`SELECT key, value FROM state
|
|
547
|
+
WHERE namespace = ?
|
|
548
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))`)
|
|
549
|
+
.all(namespace);
|
|
550
|
+
const state = {};
|
|
551
|
+
for (const row of rows) {
|
|
552
|
+
state[row.key] = row.value;
|
|
553
|
+
}
|
|
554
|
+
if (request.query.path) {
|
|
555
|
+
const result = JSONPath({
|
|
556
|
+
path: request.query.path,
|
|
557
|
+
json: state,
|
|
558
|
+
});
|
|
559
|
+
return { result, count: Array.isArray(result) ? result.length : 1 };
|
|
560
|
+
}
|
|
561
|
+
return state;
|
|
562
|
+
});
|
|
563
|
+
/** GET /state/:namespace/:key — Read collection items for a state key. */
|
|
564
|
+
app.get('/state/:namespace/:key', (request) => {
|
|
565
|
+
const { namespace, key } = request.params;
|
|
566
|
+
const limit = parseInt(request.query.limit ?? '100', 10);
|
|
567
|
+
const order = request.query.order === 'asc' ? 'ASC' : 'DESC';
|
|
568
|
+
// First check scalar state value
|
|
569
|
+
const scalar = db
|
|
570
|
+
.prepare(`SELECT value FROM state
|
|
571
|
+
WHERE namespace = ? AND key = ?
|
|
572
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))`)
|
|
573
|
+
.get(namespace, key);
|
|
574
|
+
// Get collection items
|
|
575
|
+
const items = db
|
|
576
|
+
.prepare(`SELECT item_key, value, updated_at FROM state_items
|
|
577
|
+
WHERE namespace = ? AND key = ?
|
|
578
|
+
ORDER BY updated_at ${order}
|
|
579
|
+
LIMIT ?`)
|
|
580
|
+
.all(namespace, key, limit);
|
|
581
|
+
const mappedItems = items.map((item) => ({
|
|
582
|
+
itemKey: item.item_key,
|
|
583
|
+
value: item.value,
|
|
584
|
+
updatedAt: item.updated_at,
|
|
585
|
+
}));
|
|
586
|
+
const body = {
|
|
587
|
+
value: scalar?.value ?? null,
|
|
588
|
+
items: mappedItems,
|
|
589
|
+
count: mappedItems.length,
|
|
590
|
+
};
|
|
591
|
+
if (request.query.path) {
|
|
592
|
+
const result = JSONPath({
|
|
593
|
+
path: request.query.path,
|
|
594
|
+
json: body,
|
|
595
|
+
});
|
|
596
|
+
return { result, count: Array.isArray(result) ? result.length : 1 };
|
|
597
|
+
}
|
|
598
|
+
return body;
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, system stats, and config queries.
|
|
604
|
+
*
|
|
605
|
+
* @module routes
|
|
212
606
|
*/
|
|
213
607
|
/**
|
|
214
608
|
* Register all API routes on the Fastify instance.
|
|
215
609
|
*/
|
|
216
610
|
function registerRoutes(app, deps) {
|
|
217
|
-
const { db, scheduler } = deps;
|
|
611
|
+
const { db, scheduler, getConfig } = deps;
|
|
218
612
|
/** GET /health — Health check. */
|
|
219
613
|
app.get('/health', () => {
|
|
220
614
|
return {
|
|
@@ -234,7 +628,7 @@ function registerRoutes(app, deps) {
|
|
|
234
628
|
return { jobs: rows };
|
|
235
629
|
});
|
|
236
630
|
/** GET /jobs/:id — Single job detail. */
|
|
237
|
-
app.get('/jobs/:id',
|
|
631
|
+
app.get('/jobs/:id', (request, reply) => {
|
|
238
632
|
const job = db
|
|
239
633
|
.prepare('SELECT * FROM jobs WHERE id = ?')
|
|
240
634
|
.get(request.params.id);
|
|
@@ -263,30 +657,6 @@ function registerRoutes(app, deps) {
|
|
|
263
657
|
return { error: err instanceof Error ? err.message : 'Unknown error' };
|
|
264
658
|
}
|
|
265
659
|
});
|
|
266
|
-
/** POST /jobs/:id/enable — Enable a job. */
|
|
267
|
-
app.post('/jobs/:id/enable', (request, reply) => {
|
|
268
|
-
const result = db
|
|
269
|
-
.prepare('UPDATE jobs SET enabled = 1 WHERE id = ?')
|
|
270
|
-
.run(request.params.id);
|
|
271
|
-
if (result.changes === 0) {
|
|
272
|
-
reply.code(404);
|
|
273
|
-
return { error: 'Job not found' };
|
|
274
|
-
}
|
|
275
|
-
scheduler.reconcileNow();
|
|
276
|
-
return { ok: true };
|
|
277
|
-
});
|
|
278
|
-
/** POST /jobs/:id/disable — Disable a job. */
|
|
279
|
-
app.post('/jobs/:id/disable', (request, reply) => {
|
|
280
|
-
const result = db
|
|
281
|
-
.prepare('UPDATE jobs SET enabled = 0 WHERE id = ?')
|
|
282
|
-
.run(request.params.id);
|
|
283
|
-
if (result.changes === 0) {
|
|
284
|
-
reply.code(404);
|
|
285
|
-
return { error: 'Job not found' };
|
|
286
|
-
}
|
|
287
|
-
scheduler.reconcileNow();
|
|
288
|
-
return { ok: true };
|
|
289
|
-
});
|
|
290
660
|
/** GET /stats — Aggregate job statistics. */
|
|
291
661
|
app.get('/stats', () => {
|
|
292
662
|
const totalJobs = db
|
|
@@ -310,10 +680,25 @@ function registerRoutes(app, deps) {
|
|
|
310
680
|
errorsLastHour: errorsLastHour.count,
|
|
311
681
|
};
|
|
312
682
|
});
|
|
683
|
+
/** GET /config — Query effective configuration via JSONPath. */
|
|
684
|
+
const configHandler = createConfigQueryHandler(getConfig);
|
|
685
|
+
app.get('/config', async (request, reply) => {
|
|
686
|
+
const result = await configHandler({
|
|
687
|
+
path: request.query.path,
|
|
688
|
+
});
|
|
689
|
+
return reply.status(result.status).send(result.body);
|
|
690
|
+
});
|
|
691
|
+
// Register job CRUD routes (POST, PATCH, DELETE, PATCH enable/disable, PUT script)
|
|
692
|
+
registerJobRoutes(app, { db, scheduler });
|
|
693
|
+
// Register queue and state inspection routes
|
|
694
|
+
registerQueueRoutes(app, { db });
|
|
695
|
+
registerStateRoutes(app, { db });
|
|
313
696
|
}
|
|
314
697
|
|
|
315
698
|
/**
|
|
316
699
|
* Fastify HTTP server for runner API. Creates server instance with logging, registers routes, listens on configured port (localhost only).
|
|
700
|
+
*
|
|
701
|
+
* @module
|
|
317
702
|
*/
|
|
318
703
|
/**
|
|
319
704
|
* Create and configure the Fastify server. Routes are registered but server is not started.
|
|
@@ -334,12 +719,18 @@ function createServer(deps) {
|
|
|
334
719
|
}
|
|
335
720
|
: false,
|
|
336
721
|
});
|
|
337
|
-
registerRoutes(app,
|
|
722
|
+
registerRoutes(app, {
|
|
723
|
+
db: deps.db,
|
|
724
|
+
scheduler: deps.scheduler,
|
|
725
|
+
getConfig: deps.getConfig,
|
|
726
|
+
});
|
|
338
727
|
return app;
|
|
339
728
|
}
|
|
340
729
|
|
|
341
730
|
/**
|
|
342
731
|
* Database maintenance tasks: run retention pruning and expired state cleanup.
|
|
732
|
+
*
|
|
733
|
+
* @module
|
|
343
734
|
*/
|
|
344
735
|
/** Delete runs older than the configured retention period. */
|
|
345
736
|
function pruneOldRuns(db, days, logger) {
|
|
@@ -406,6 +797,8 @@ function createMaintenance(db, config, logger) {
|
|
|
406
797
|
|
|
407
798
|
/**
|
|
408
799
|
* Shared HTTP utility for making POST requests.
|
|
800
|
+
*
|
|
801
|
+
* @module
|
|
409
802
|
*/
|
|
410
803
|
/** Make an HTTP/HTTPS POST request. Returns the parsed JSON response body. */
|
|
411
804
|
function httpPost(url, headers, body, timeoutMs = 30000) {
|
|
@@ -453,6 +846,8 @@ function httpPost(url, headers, body, timeoutMs = 30000) {
|
|
|
453
846
|
|
|
454
847
|
/**
|
|
455
848
|
* OpenClaw Gateway HTTP client for spawning and monitoring sessions.
|
|
849
|
+
*
|
|
850
|
+
* @module
|
|
456
851
|
*/
|
|
457
852
|
/** Make an HTTP POST request to the Gateway /tools/invoke endpoint. */
|
|
458
853
|
function invokeGateway(url, token, tool, args, timeoutMs = 30000) {
|
|
@@ -519,6 +914,8 @@ function createGatewayClient(options) {
|
|
|
519
914
|
|
|
520
915
|
/**
|
|
521
916
|
* Slack notification module. Sends job completion/failure messages via Slack Web API (chat.postMessage). Falls back gracefully if no token.
|
|
917
|
+
*
|
|
918
|
+
* @module
|
|
522
919
|
*/
|
|
523
920
|
/** Post a message to Slack via chat.postMessage API. */
|
|
524
921
|
async function postToSlack(token, channel, text) {
|
|
@@ -536,21 +933,21 @@ function createNotifier(config) {
|
|
|
536
933
|
return {
|
|
537
934
|
async notifySuccess(jobName, durationMs, channel) {
|
|
538
935
|
if (!slackToken) {
|
|
539
|
-
console.warn(`No Slack token configured
|
|
936
|
+
console.warn(`No Slack token configured \u2014 skipping success notification for ${jobName}`);
|
|
540
937
|
return;
|
|
541
938
|
}
|
|
542
939
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
543
|
-
const text =
|
|
940
|
+
const text = `\u2705 *${jobName}* completed (${durationSec}s)`;
|
|
544
941
|
await postToSlack(slackToken, channel, text);
|
|
545
942
|
},
|
|
546
943
|
async notifyFailure(jobName, durationMs, error, channel) {
|
|
547
944
|
if (!slackToken) {
|
|
548
|
-
console.warn(`No Slack token configured
|
|
945
|
+
console.warn(`No Slack token configured \u2014 skipping failure notification for ${jobName}`);
|
|
549
946
|
return;
|
|
550
947
|
}
|
|
551
948
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
552
949
|
const errorMsg = error ? `: ${error}` : '';
|
|
553
|
-
const text =
|
|
950
|
+
const text = `\u274c *${jobName}* failed (${durationSec}s)${errorMsg}`;
|
|
554
951
|
await postToSlack(slackToken, channel, text);
|
|
555
952
|
},
|
|
556
953
|
async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
|
|
@@ -570,6 +967,8 @@ function createNotifier(config) {
|
|
|
570
967
|
|
|
571
968
|
/**
|
|
572
969
|
* Job executor. Spawns job scripts as child processes, captures output, parses result metadata, enforces timeouts.
|
|
970
|
+
*
|
|
971
|
+
* @module
|
|
573
972
|
*/
|
|
574
973
|
/** Ring buffer for capturing last N lines of output. */
|
|
575
974
|
class RingBuffer {
|
|
@@ -631,14 +1030,23 @@ function resolveCommand(script) {
|
|
|
631
1030
|
* Execute a job script as a child process. Captures output, parses metadata, enforces timeout.
|
|
632
1031
|
*/
|
|
633
1032
|
function executeJob(options) {
|
|
634
|
-
const { script, dbPath, jobId, runId, timeoutMs, commandResolver } = options;
|
|
1033
|
+
const { script, dbPath, jobId, runId, timeoutMs, commandResolver, sourceType = 'path', } = options;
|
|
635
1034
|
const startTime = Date.now();
|
|
1035
|
+
// For inline scripts, write to a temp file and clean up after.
|
|
1036
|
+
let tempFile = null;
|
|
1037
|
+
let effectiveScript = script;
|
|
1038
|
+
if (sourceType === 'inline') {
|
|
1039
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'jr-inline-'));
|
|
1040
|
+
tempFile = join(tempDir, 'inline.js');
|
|
1041
|
+
writeFileSync(tempFile, script);
|
|
1042
|
+
effectiveScript = tempFile;
|
|
1043
|
+
}
|
|
636
1044
|
return new Promise((resolve) => {
|
|
637
1045
|
const stdoutBuffer = new RingBuffer(100);
|
|
638
1046
|
const stderrBuffer = new RingBuffer(100);
|
|
639
1047
|
const { command, args } = commandResolver
|
|
640
|
-
? commandResolver(
|
|
641
|
-
: resolveCommand(
|
|
1048
|
+
? commandResolver(effectiveScript)
|
|
1049
|
+
: resolveCommand(effectiveScript);
|
|
642
1050
|
const child = spawn(command, args, {
|
|
643
1051
|
env: {
|
|
644
1052
|
...process.env,
|
|
@@ -674,6 +1082,7 @@ function executeJob(options) {
|
|
|
674
1082
|
child.on('close', (exitCode) => {
|
|
675
1083
|
if (timeoutHandle)
|
|
676
1084
|
clearTimeout(timeoutHandle);
|
|
1085
|
+
cleanupTempFile(tempFile);
|
|
677
1086
|
const durationMs = Date.now() - startTime;
|
|
678
1087
|
const stdoutTail = stdoutBuffer.getAll();
|
|
679
1088
|
const stderrTail = stderrBuffer.getAll();
|
|
@@ -718,6 +1127,7 @@ function executeJob(options) {
|
|
|
718
1127
|
child.on('error', (err) => {
|
|
719
1128
|
if (timeoutHandle)
|
|
720
1129
|
clearTimeout(timeoutHandle);
|
|
1130
|
+
cleanupTempFile(tempFile);
|
|
721
1131
|
const durationMs = Date.now() - startTime;
|
|
722
1132
|
resolve({
|
|
723
1133
|
status: 'error',
|
|
@@ -732,31 +1142,73 @@ function executeJob(options) {
|
|
|
732
1142
|
});
|
|
733
1143
|
});
|
|
734
1144
|
}
|
|
1145
|
+
/** Remove a temp file created for inline script execution. */
|
|
1146
|
+
function cleanupTempFile(tempFile) {
|
|
1147
|
+
if (!tempFile)
|
|
1148
|
+
return;
|
|
1149
|
+
try {
|
|
1150
|
+
unlinkSync(tempFile);
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
// Ignore cleanup errors
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
735
1156
|
|
|
736
1157
|
/**
|
|
737
|
-
*
|
|
1158
|
+
* Schedule registration and reconciliation. Supports cron (croner) and RRStack schedule formats via unified setTimeout-based scheduling.
|
|
1159
|
+
*
|
|
1160
|
+
* @module
|
|
738
1161
|
*/
|
|
1162
|
+
/** Maximum setTimeout delay (Node.js limit: ~24.8 days). */
|
|
1163
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
739
1164
|
function createCronRegistry(deps) {
|
|
740
1165
|
const { db, logger, onScheduledRun } = deps;
|
|
741
|
-
const
|
|
742
|
-
const
|
|
1166
|
+
const handles = new Map();
|
|
1167
|
+
const scheduleStrings = new Map();
|
|
743
1168
|
const failedRegistrations = new Set();
|
|
744
|
-
|
|
1169
|
+
/**
|
|
1170
|
+
* Schedule the next fire for a job. Computes next fire time, sets a
|
|
1171
|
+
* setTimeout, and re-arms after each fire.
|
|
1172
|
+
*/
|
|
1173
|
+
function scheduleNext(jobId, schedule) {
|
|
1174
|
+
const nextDate = getNextFireTime(schedule);
|
|
1175
|
+
if (!nextDate) {
|
|
1176
|
+
logger.warn({ jobId }, 'No upcoming fire time, job will not fire');
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const delayMs = Math.max(0, nextDate.getTime() - Date.now());
|
|
1180
|
+
// Node.js setTimeout max is ~24.8 days. If delay exceeds that,
|
|
1181
|
+
// set an intermediate wakeup and re-check.
|
|
1182
|
+
const effectiveDelay = Math.min(delayMs, MAX_TIMEOUT_MS);
|
|
1183
|
+
const isIntermediate = delayMs > MAX_TIMEOUT_MS;
|
|
1184
|
+
const timeout = setTimeout(() => {
|
|
1185
|
+
if (isIntermediate) {
|
|
1186
|
+
// Woke up early just to re-check; schedule again.
|
|
1187
|
+
scheduleNext(jobId, schedule);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
// Re-read job from DB to get current configuration
|
|
1191
|
+
const currentJob = db
|
|
1192
|
+
.prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
|
|
1193
|
+
.get(jobId);
|
|
1194
|
+
if (!currentJob) {
|
|
1195
|
+
logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
onScheduledRun(currentJob);
|
|
1199
|
+
// Re-arm for the next occurrence
|
|
1200
|
+
scheduleNext(jobId, schedule);
|
|
1201
|
+
}, effectiveDelay);
|
|
1202
|
+
handles.set(jobId, {
|
|
1203
|
+
cancel: () => {
|
|
1204
|
+
clearTimeout(timeout);
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
function registerSchedule(job) {
|
|
745
1209
|
try {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
// Re-read job from DB to get current configuration
|
|
749
|
-
const currentJob = db
|
|
750
|
-
.prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
|
|
751
|
-
.get(jobId);
|
|
752
|
-
if (!currentJob) {
|
|
753
|
-
logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
onScheduledRun(currentJob);
|
|
757
|
-
});
|
|
758
|
-
crons.set(job.id, cron);
|
|
759
|
-
cronSchedules.set(job.id, job.schedule);
|
|
1210
|
+
scheduleNext(job.id, job.schedule);
|
|
1211
|
+
scheduleStrings.set(job.id, job.schedule);
|
|
760
1212
|
failedRegistrations.delete(job.id);
|
|
761
1213
|
logger.info({ jobId: job.id, schedule: job.schedule }, 'Scheduled job');
|
|
762
1214
|
return true;
|
|
@@ -773,39 +1225,39 @@ function createCronRegistry(deps) {
|
|
|
773
1225
|
.all();
|
|
774
1226
|
const enabledById = new Map(enabledJobs.map((j) => [j.id, j]));
|
|
775
1227
|
// Remove disabled/deleted jobs
|
|
776
|
-
for (const [jobId,
|
|
1228
|
+
for (const [jobId, handle] of handles.entries()) {
|
|
777
1229
|
if (!enabledById.has(jobId)) {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1230
|
+
handle.cancel();
|
|
1231
|
+
handles.delete(jobId);
|
|
1232
|
+
scheduleStrings.delete(jobId);
|
|
781
1233
|
}
|
|
782
1234
|
}
|
|
783
1235
|
const failedIds = [];
|
|
784
1236
|
// Add or update enabled jobs
|
|
785
1237
|
for (const job of enabledJobs) {
|
|
786
|
-
const
|
|
787
|
-
const existingSchedule =
|
|
788
|
-
if (!
|
|
789
|
-
if (!
|
|
1238
|
+
const existingHandle = handles.get(job.id);
|
|
1239
|
+
const existingSchedule = scheduleStrings.get(job.id);
|
|
1240
|
+
if (!existingHandle) {
|
|
1241
|
+
if (!registerSchedule(job))
|
|
790
1242
|
failedIds.push(job.id);
|
|
791
1243
|
continue;
|
|
792
1244
|
}
|
|
793
1245
|
if (existingSchedule !== job.schedule) {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (!
|
|
1246
|
+
existingHandle.cancel();
|
|
1247
|
+
handles.delete(job.id);
|
|
1248
|
+
scheduleStrings.delete(job.id);
|
|
1249
|
+
if (!registerSchedule(job))
|
|
798
1250
|
failedIds.push(job.id);
|
|
799
1251
|
}
|
|
800
1252
|
}
|
|
801
1253
|
return { totalEnabled: enabledJobs.length, failedIds };
|
|
802
1254
|
}
|
|
803
1255
|
function stopAll() {
|
|
804
|
-
for (const
|
|
805
|
-
|
|
1256
|
+
for (const handle of handles.values()) {
|
|
1257
|
+
handle.cancel();
|
|
806
1258
|
}
|
|
807
|
-
|
|
808
|
-
|
|
1259
|
+
handles.clear();
|
|
1260
|
+
scheduleStrings.clear();
|
|
809
1261
|
}
|
|
810
1262
|
return {
|
|
811
1263
|
reconcile,
|
|
@@ -818,6 +1270,8 @@ function createCronRegistry(deps) {
|
|
|
818
1270
|
|
|
819
1271
|
/**
|
|
820
1272
|
* Run record repository for managing job execution records.
|
|
1273
|
+
*
|
|
1274
|
+
* @module
|
|
821
1275
|
*/
|
|
822
1276
|
/** Create a run repository for the given database connection. */
|
|
823
1277
|
function createRunRepository(db) {
|
|
@@ -839,6 +1293,8 @@ function createRunRepository(db) {
|
|
|
839
1293
|
|
|
840
1294
|
/**
|
|
841
1295
|
* Session executor for job type='session'. Spawns OpenClaw Gateway sessions and polls for completion.
|
|
1296
|
+
*
|
|
1297
|
+
* @module
|
|
842
1298
|
*/
|
|
843
1299
|
/** File extensions that indicate a script rather than a prompt. */
|
|
844
1300
|
const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ps1', '.cmd', '.bat'];
|
|
@@ -938,7 +1394,9 @@ async function executeSession(options) {
|
|
|
938
1394
|
}
|
|
939
1395
|
|
|
940
1396
|
/**
|
|
941
|
-
*
|
|
1397
|
+
* Job scheduler. Loads enabled jobs, registers schedules (cron or rrstack), manages execution, respects overlap policies and concurrency limits.
|
|
1398
|
+
*
|
|
1399
|
+
* @module
|
|
942
1400
|
*/
|
|
943
1401
|
// JobRow is imported from cron-registry
|
|
944
1402
|
/**
|
|
@@ -958,7 +1416,7 @@ function createScheduler(deps) {
|
|
|
958
1416
|
let reconcileInterval = null;
|
|
959
1417
|
/** Execute a job: create run record, run script, update record, send notifications. */
|
|
960
1418
|
async function runJob(job, trigger) {
|
|
961
|
-
const { id, name, script, type, timeout_ms, on_success, on_failure } = job;
|
|
1419
|
+
const { id, name, script, type, timeout_ms, on_success, on_failure, source_type, } = job;
|
|
962
1420
|
// Check concurrency limit
|
|
963
1421
|
if (runningJobs.size >= config.maxConcurrency) {
|
|
964
1422
|
logger.warn({ jobId: id }, 'Max concurrency reached, skipping job');
|
|
@@ -989,6 +1447,7 @@ function createScheduler(deps) {
|
|
|
989
1447
|
jobId: id,
|
|
990
1448
|
runId,
|
|
991
1449
|
timeoutMs: timeout_ms ?? undefined,
|
|
1450
|
+
sourceType: source_type ?? 'path',
|
|
992
1451
|
});
|
|
993
1452
|
}
|
|
994
1453
|
runRepository.finishRun(runId, result);
|
|
@@ -1078,6 +1537,8 @@ function createScheduler(deps) {
|
|
|
1078
1537
|
|
|
1079
1538
|
/**
|
|
1080
1539
|
* Main runner orchestrator. Wires up database, scheduler, API server, and handles graceful shutdown on SIGTERM/SIGINT.
|
|
1540
|
+
*
|
|
1541
|
+
* @module
|
|
1081
1542
|
*/
|
|
1082
1543
|
/**
|
|
1083
1544
|
* Create the runner. Initializes database, scheduler, API server, and sets up graceful shutdown.
|
|
@@ -1145,9 +1606,10 @@ function createRunner(config, deps) {
|
|
|
1145
1606
|
server = createServer({
|
|
1146
1607
|
db,
|
|
1147
1608
|
scheduler,
|
|
1609
|
+
getConfig: () => config,
|
|
1148
1610
|
loggerConfig: { level: config.log.level, file: config.log.file },
|
|
1149
1611
|
});
|
|
1150
|
-
await server.listen({ port: config.port, host:
|
|
1612
|
+
await server.listen({ port: config.port, host: config.host });
|
|
1151
1613
|
logger.info({ port: config.port }, 'API server listening');
|
|
1152
1614
|
// Graceful shutdown
|
|
1153
1615
|
const shutdown = async (signal) => {
|
|
@@ -1218,6 +1680,8 @@ const gatewaySchema = z.object({
|
|
|
1218
1680
|
const runnerConfigSchema = z.object({
|
|
1219
1681
|
/** HTTP server port for the runner API. */
|
|
1220
1682
|
port: z.number().default(1937),
|
|
1683
|
+
/** Bind address for the HTTP server. */
|
|
1684
|
+
host: z.string().default('127.0.0.1'),
|
|
1221
1685
|
/** Path to SQLite database file. */
|
|
1222
1686
|
dbPath: z.string().default('./data/runner.sqlite'),
|
|
1223
1687
|
/** Maximum number of concurrent job executions. */
|
|
@@ -1249,6 +1713,7 @@ const runnerConfigSchema = z.object({
|
|
|
1249
1713
|
/** Minimal starter config template. */
|
|
1250
1714
|
const INIT_CONFIG_TEMPLATE = {
|
|
1251
1715
|
port: 1937,
|
|
1716
|
+
host: '127.0.0.1',
|
|
1252
1717
|
dbPath: './data/runner.sqlite',
|
|
1253
1718
|
maxConcurrency: 4,
|
|
1254
1719
|
runRetentionDays: 30,
|
|
@@ -1523,12 +1988,10 @@ program
|
|
|
1523
1988
|
.option('-c, --config <path>', 'Path to config file')
|
|
1524
1989
|
.action((options) => {
|
|
1525
1990
|
const config = loadConfig(options.config);
|
|
1526
|
-
// Validate schedule expression before inserting
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
catch (err) {
|
|
1531
|
-
console.error(`Invalid schedule expression: ${err instanceof Error ? err.message : String(err)}`);
|
|
1991
|
+
// Validate schedule expression (cron or rrstack) before inserting
|
|
1992
|
+
const scheduleValidation = validateSchedule(options.schedule);
|
|
1993
|
+
if (!scheduleValidation.valid) {
|
|
1994
|
+
console.error(`Invalid schedule: ${scheduleValidation.error}`);
|
|
1532
1995
|
process.exit(1);
|
|
1533
1996
|
}
|
|
1534
1997
|
// Validate overlap_policy
|