@karmaniverous/jeeves-runner 0.5.0 → 0.7.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/mjs/index.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import { z } from 'zod';
2
- import { mkdirSync, existsSync, readFileSync } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, join, extname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { pino } from 'pino';
4
6
  import Fastify from 'fastify';
5
7
  import { createConfigQueryHandler } from '@karmaniverous/jeeves';
6
- import { dirname, extname } from 'node:path';
8
+ import { RRStack } from '@karmaniverous/rrstack';
9
+ import { Cron } from 'croner';
10
+ import { JSONPath } from 'jsonpath-plus';
7
11
  import { DatabaseSync } from 'node:sqlite';
8
12
  import { request as request$1 } from 'node:http';
9
13
  import { request } from 'node:https';
10
14
  import { spawn } from 'node:child_process';
11
- import { Cron } from 'croner';
12
- import { JSONPath } from 'jsonpath-plus';
15
+ import { tmpdir } from 'node:os';
13
16
 
14
17
  /**
15
18
  * Runner configuration schema and types.
@@ -45,6 +48,8 @@ const gatewaySchema = z.object({
45
48
  const runnerConfigSchema = z.object({
46
49
  /** HTTP server port for the runner API. */
47
50
  port: z.number().default(1937),
51
+ /** Bind address for the HTTP server. */
52
+ host: z.string().default('127.0.0.1'),
48
53
  /** Path to SQLite database file. */
49
54
  dbPath: z.string().default('./data/runner.sqlite'),
50
55
  /** Maximum number of concurrent job executions. */
@@ -91,8 +96,8 @@ const jobSchema = z.object({
91
96
  enabled: z.boolean().default(true),
92
97
  /** Optional execution timeout in milliseconds. */
93
98
  timeoutMs: z.number().optional(),
94
- /** Policy for handling overlapping job executions (skip, queue, or allow). */
95
- overlapPolicy: z.enum(['skip', 'queue', 'allow']).default('skip'),
99
+ /** Policy for handling overlapping job executions (skip or allow). */
100
+ overlapPolicy: z.enum(['skip', 'allow']).default('skip'),
96
101
  /** Slack channel ID for failure notifications. */
97
102
  onFailure: z.string().nullable().default(null),
98
103
  /** Slack channel ID for success notifications. */
@@ -170,6 +175,385 @@ const runSchema = z.object({
170
175
  trigger: runTriggerSchema.default('schedule'),
171
176
  });
172
177
 
178
+ /**
179
+ * Schedule parsing, validation, and next-fire-time computation for cron and RRStack formats.
180
+ *
181
+ * @module
182
+ */
183
+ /**
184
+ * Attempt to parse a schedule string as RRStack JSON (non-null, non-array
185
+ * object). Returns the parsed options on success, or null if the string
186
+ * is not a JSON object.
187
+ */
188
+ function tryParseRRStack(schedule) {
189
+ try {
190
+ const parsed = JSON.parse(schedule);
191
+ if (typeof parsed === 'object' &&
192
+ parsed !== null &&
193
+ !Array.isArray(parsed)) {
194
+ return parsed;
195
+ }
196
+ return null;
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ /**
203
+ * Compute the next fire time for a schedule string.
204
+ * Supports cron expressions (via croner) and RRStack JSON (via nextEvent).
205
+ *
206
+ * @param schedule - Cron expression or RRStack JSON string.
207
+ * @returns Next fire time as a Date, or null if none can be determined.
208
+ */
209
+ function getNextFireTime(schedule) {
210
+ const rrOpts = tryParseRRStack(schedule);
211
+ if (rrOpts) {
212
+ const stack = new RRStack(rrOpts);
213
+ const next = stack.nextEvent();
214
+ if (!next)
215
+ return null;
216
+ const unit = stack.timeUnit;
217
+ return new Date(unit === 's' ? next.at * 1000 : next.at);
218
+ }
219
+ const cron = new Cron(schedule);
220
+ return cron.nextRun() ?? null;
221
+ }
222
+ /**
223
+ * Validate a schedule string as either a cron expression or RRStack JSON.
224
+ *
225
+ * @param schedule - Cron expression or RRStack JSON string.
226
+ * @returns Validation result with format on success, or error message on failure.
227
+ */
228
+ function validateSchedule(schedule) {
229
+ const rrOpts = tryParseRRStack(schedule);
230
+ if (rrOpts) {
231
+ try {
232
+ new RRStack(rrOpts);
233
+ return { valid: true, format: 'rrstack' };
234
+ }
235
+ catch (err) {
236
+ return {
237
+ valid: false,
238
+ error: `Invalid RRStack schedule: ${err instanceof Error ? err.message : String(err)}`,
239
+ };
240
+ }
241
+ }
242
+ try {
243
+ new Cron(schedule);
244
+ return { valid: true, format: 'cron' };
245
+ }
246
+ catch (err) {
247
+ return {
248
+ valid: false,
249
+ error: `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`,
250
+ };
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Job CRUD routes: create, update, delete, enable/disable, and script update endpoints.
256
+ *
257
+ * @module
258
+ */
259
+ /** Zod schema for job creation/update request body. */
260
+ const createJobSchema = z.object({
261
+ id: z.string().min(1),
262
+ name: z.string().min(1),
263
+ schedule: z.string().min(1),
264
+ script: z.string().min(1),
265
+ source_type: z.enum(['path', 'inline']).default('path'),
266
+ type: z.enum(['script', 'session']).default('script'),
267
+ timeout_seconds: z.number().positive().optional(),
268
+ overlap_policy: z.enum(['skip', 'allow']).default('skip'),
269
+ enabled: z.boolean().default(true),
270
+ description: z.string().optional(),
271
+ on_failure: z.string().nullable().optional(),
272
+ on_success: z.string().nullable().optional(),
273
+ });
274
+ /** Zod schema for job update (all fields optional except what's set). */
275
+ const updateJobSchema = createJobSchema.omit({ id: true }).partial();
276
+ /** Zod schema for script update request body. */
277
+ const updateScriptSchema = z.object({
278
+ script: z.string().min(1),
279
+ source_type: z.enum(['path', 'inline']).optional(),
280
+ });
281
+ /** Standard error message for missing job resources. */
282
+ const JOB_NOT_FOUND = 'Job not found';
283
+ /**
284
+ * Register job CRUD routes on the Fastify instance.
285
+ */
286
+ function registerJobRoutes(app, deps) {
287
+ const { db, scheduler } = deps;
288
+ /** POST /jobs — Create a new job. */
289
+ app.post('/jobs', (request, reply) => {
290
+ const parsed = createJobSchema.safeParse(request.body);
291
+ if (!parsed.success) {
292
+ reply.code(400);
293
+ return { error: parsed.error.message };
294
+ }
295
+ const data = parsed.data;
296
+ const validation = validateSchedule(data.schedule);
297
+ if (!validation.valid) {
298
+ reply.code(400);
299
+ return { error: validation.error };
300
+ }
301
+ const timeoutMs = data.timeout_seconds ? data.timeout_seconds * 1000 : null;
302
+ db.prepare(`INSERT INTO jobs (id, name, schedule, script, source_type, type, timeout_ms,
303
+ overlap_policy, enabled, description, on_failure, on_success)
304
+ 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);
305
+ scheduler.reconcileNow();
306
+ reply.code(201);
307
+ return { ok: true, id: data.id };
308
+ });
309
+ /** PATCH /jobs/:id — Partial update of an existing job. */
310
+ app.patch('/jobs/:id', (request, reply) => {
311
+ const parsed = updateJobSchema.safeParse(request.body);
312
+ if (!parsed.success) {
313
+ reply.code(400);
314
+ return { error: parsed.error.message };
315
+ }
316
+ const existing = db
317
+ .prepare('SELECT id FROM jobs WHERE id = ?')
318
+ .get(request.params.id);
319
+ if (!existing) {
320
+ reply.code(404);
321
+ return { error: JOB_NOT_FOUND };
322
+ }
323
+ const data = parsed.data;
324
+ if (data.schedule) {
325
+ const validation = validateSchedule(data.schedule);
326
+ if (!validation.valid) {
327
+ reply.code(400);
328
+ return { error: validation.error };
329
+ }
330
+ }
331
+ /** Map input field → DB column + value transform. */
332
+ const fieldMap = [
333
+ { input: 'name', column: 'name' },
334
+ { input: 'schedule', column: 'schedule' },
335
+ { input: 'script', column: 'script' },
336
+ { input: 'source_type', column: 'source_type' },
337
+ { input: 'type', column: 'type' },
338
+ {
339
+ input: 'timeout_seconds',
340
+ column: 'timeout_ms',
341
+ transform: (v) => v * 1000,
342
+ },
343
+ { input: 'overlap_policy', column: 'overlap_policy' },
344
+ {
345
+ input: 'enabled',
346
+ column: 'enabled',
347
+ transform: (v) => (v ? 1 : 0),
348
+ },
349
+ { input: 'description', column: 'description' },
350
+ { input: 'on_failure', column: 'on_failure' },
351
+ { input: 'on_success', column: 'on_success' },
352
+ ];
353
+ const sets = [];
354
+ const values = [];
355
+ for (const { input, column, transform } of fieldMap) {
356
+ if (data[input] !== undefined) {
357
+ sets.push(`${column} = ?`);
358
+ values.push(transform ? transform(data[input]) : data[input]);
359
+ }
360
+ }
361
+ if (sets.length > 0) {
362
+ sets.push("updated_at = datetime('now')");
363
+ values.push(request.params.id);
364
+ db.prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values);
365
+ }
366
+ scheduler.reconcileNow();
367
+ return { ok: true };
368
+ });
369
+ /** DELETE /jobs/:id — Delete a job and its runs. */
370
+ app.delete('/jobs/:id', (request, reply) => {
371
+ db.prepare('DELETE FROM runs WHERE job_id = ?').run(request.params.id);
372
+ const result = db
373
+ .prepare('DELETE FROM jobs WHERE id = ?')
374
+ .run(request.params.id);
375
+ if (result.changes === 0) {
376
+ reply.code(404);
377
+ return { error: JOB_NOT_FOUND };
378
+ }
379
+ scheduler.reconcileNow();
380
+ return { ok: true };
381
+ });
382
+ /** Register a PATCH toggle endpoint (enable or disable). */
383
+ function registerToggle(path, enabledValue) {
384
+ app.patch(path, (request, reply) => {
385
+ const result = db
386
+ .prepare('UPDATE jobs SET enabled = ? WHERE id = ?')
387
+ .run(enabledValue, request.params.id);
388
+ if (result.changes === 0) {
389
+ reply.code(404);
390
+ return { error: JOB_NOT_FOUND };
391
+ }
392
+ scheduler.reconcileNow();
393
+ return { ok: true };
394
+ });
395
+ }
396
+ registerToggle('/jobs/:id/enable', 1);
397
+ registerToggle('/jobs/:id/disable', 0);
398
+ /** PUT /jobs/:id/script — Update job script and source_type. */
399
+ app.put('/jobs/:id/script', (request, reply) => {
400
+ const parsed = updateScriptSchema.safeParse(request.body);
401
+ if (!parsed.success) {
402
+ reply.code(400);
403
+ return { error: parsed.error.message };
404
+ }
405
+ const data = parsed.data;
406
+ const result = data.source_type
407
+ ? db
408
+ .prepare('UPDATE jobs SET script = ?, source_type = ? WHERE id = ?')
409
+ .run(data.script, data.source_type, request.params.id)
410
+ : db
411
+ .prepare('UPDATE jobs SET script = ? WHERE id = ?')
412
+ .run(data.script, request.params.id);
413
+ if (result.changes === 0) {
414
+ reply.code(404);
415
+ return { error: JOB_NOT_FOUND };
416
+ }
417
+ scheduler.reconcileNow();
418
+ return { ok: true };
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Queue inspection routes: list queues, queue status, and peek at pending items.
424
+ *
425
+ * @module
426
+ */
427
+ /**
428
+ * Register queue inspection routes on the Fastify instance.
429
+ */
430
+ function registerQueueRoutes(app, deps) {
431
+ const { db } = deps;
432
+ /** GET /queues — List all distinct queues that have items. */
433
+ app.get('/queues', () => {
434
+ const rows = db
435
+ .prepare('SELECT DISTINCT queue_id FROM queue_items')
436
+ .all();
437
+ return { queues: rows.map((r) => r.queue_id) };
438
+ });
439
+ /** GET /queues/:name/status — Queue depth, claimed count, failed count, oldest age. */
440
+ app.get('/queues/:name/status', (request) => {
441
+ const row = db
442
+ .prepare(`SELECT
443
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS depth,
444
+ SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) AS claimed,
445
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
446
+ MIN(CASE WHEN status = 'pending' THEN created_at END) AS oldest
447
+ FROM queue_items
448
+ WHERE queue_id = ?`)
449
+ .get(request.params.name);
450
+ return {
451
+ depth: row.depth ?? 0,
452
+ claimedCount: row.claimed ?? 0,
453
+ failedCount: row.failed ?? 0,
454
+ oldestAge: row.oldest
455
+ ? Date.now() - new Date(row.oldest).getTime()
456
+ : null,
457
+ };
458
+ });
459
+ /** GET /queues/:name/peek — Non-claiming read of pending items. */
460
+ app.get('/queues/:name/peek', (request) => {
461
+ const name = request.params.name;
462
+ const limit = parseInt(request.query.limit ?? '10', 10);
463
+ const items = db
464
+ .prepare(`SELECT id, payload, priority, created_at FROM queue_items
465
+ WHERE queue_id = ? AND status = 'pending'
466
+ ORDER BY priority DESC, created_at
467
+ LIMIT ?`)
468
+ .all(name, limit);
469
+ return {
470
+ items: items.map((item) => ({
471
+ id: item.id,
472
+ payload: JSON.parse(item.payload),
473
+ priority: item.priority,
474
+ createdAt: item.created_at,
475
+ })),
476
+ };
477
+ });
478
+ }
479
+
480
+ /**
481
+ * State inspection routes: list namespaces, read scalar state, and read collection items.
482
+ *
483
+ * @module
484
+ */
485
+ /**
486
+ * Register state inspection routes on the Fastify instance.
487
+ */
488
+ function registerStateRoutes(app, deps) {
489
+ const { db } = deps;
490
+ /** GET /state — List all distinct namespaces. */
491
+ app.get('/state', () => {
492
+ const rows = db
493
+ .prepare('SELECT DISTINCT namespace FROM state')
494
+ .all();
495
+ return { namespaces: rows.map((r) => r.namespace) };
496
+ });
497
+ /** GET /state/:namespace — Materialise all scalar state as key-value map. */
498
+ app.get('/state/:namespace', (request) => {
499
+ const { namespace } = request.params;
500
+ const rows = db
501
+ .prepare(`SELECT key, value FROM state
502
+ WHERE namespace = ?
503
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`)
504
+ .all(namespace);
505
+ const state = {};
506
+ for (const row of rows) {
507
+ state[row.key] = row.value;
508
+ }
509
+ if (request.query.path) {
510
+ const result = JSONPath({
511
+ path: request.query.path,
512
+ json: state,
513
+ });
514
+ return { result, count: Array.isArray(result) ? result.length : 1 };
515
+ }
516
+ return state;
517
+ });
518
+ /** GET /state/:namespace/:key — Read collection items for a state key. */
519
+ app.get('/state/:namespace/:key', (request) => {
520
+ const { namespace, key } = request.params;
521
+ const limit = parseInt(request.query.limit ?? '100', 10);
522
+ const order = request.query.order === 'asc' ? 'ASC' : 'DESC';
523
+ // First check scalar state value
524
+ const scalar = db
525
+ .prepare(`SELECT value FROM state
526
+ WHERE namespace = ? AND key = ?
527
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`)
528
+ .get(namespace, key);
529
+ // Get collection items
530
+ const items = db
531
+ .prepare(`SELECT item_key, value, updated_at FROM state_items
532
+ WHERE namespace = ? AND key = ?
533
+ ORDER BY updated_at ${order}
534
+ LIMIT ?`)
535
+ .all(namespace, key, limit);
536
+ const mappedItems = items.map((item) => ({
537
+ itemKey: item.item_key,
538
+ value: item.value,
539
+ updatedAt: item.updated_at,
540
+ }));
541
+ const body = {
542
+ value: scalar?.value ?? null,
543
+ items: mappedItems,
544
+ count: mappedItems.length,
545
+ };
546
+ if (request.query.path) {
547
+ const result = JSONPath({
548
+ path: request.query.path,
549
+ json: body,
550
+ });
551
+ return { result, count: Array.isArray(result) ? result.length : 1 };
552
+ }
553
+ return body;
554
+ });
555
+ }
556
+
173
557
  /**
174
558
  * Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, system stats, and config queries.
175
559
  *
@@ -179,13 +563,31 @@ const runSchema = z.object({
179
563
  * Register all API routes on the Fastify instance.
180
564
  */
181
565
  function registerRoutes(app, deps) {
182
- const { db, scheduler, getConfig } = deps;
183
- /** GET /healthHealth check. */
184
- app.get('/health', () => {
566
+ const { db, scheduler, getConfig, version } = deps;
567
+ /** GET /statusUnified status endpoint (single health/metadata entrypoint). */
568
+ app.get('/status', () => {
569
+ const totalJobs = db
570
+ .prepare('SELECT COUNT(*) as count FROM jobs')
571
+ .get();
572
+ const runningCount = scheduler.getRunningJobs().length;
573
+ const failedCount = scheduler.getFailedRegistrations().length;
574
+ const okLastHour = db
575
+ .prepare(`SELECT COUNT(*) as count FROM runs
576
+ WHERE status = 'ok' AND started_at > datetime('now', '-1 hour')`)
577
+ .get();
578
+ const errorsLastHour = db
579
+ .prepare(`SELECT COUNT(*) as count FROM runs
580
+ WHERE status IN ('error', 'timeout') AND started_at > datetime('now', '-1 hour')`)
581
+ .get();
185
582
  return {
186
- ok: true,
583
+ status: 'ok',
584
+ version,
187
585
  uptime: process.uptime(),
188
- failedRegistrations: scheduler.getFailedRegistrations().length,
586
+ totalJobs: totalJobs.count,
587
+ running: runningCount,
588
+ failedRegistrations: failedCount,
589
+ okLastHour: okLastHour.count,
590
+ errorsLastHour: errorsLastHour.count,
189
591
  };
190
592
  });
191
593
  /** GET /jobs — List all jobs with last run status. */
@@ -199,7 +601,7 @@ function registerRoutes(app, deps) {
199
601
  return { jobs: rows };
200
602
  });
201
603
  /** GET /jobs/:id — Single job detail. */
202
- app.get('/jobs/:id', async (request, reply) => {
604
+ app.get('/jobs/:id', (request, reply) => {
203
605
  const job = db
204
606
  .prepare('SELECT * FROM jobs WHERE id = ?')
205
607
  .get(request.params.id);
@@ -228,53 +630,6 @@ function registerRoutes(app, deps) {
228
630
  return { error: err instanceof Error ? err.message : 'Unknown error' };
229
631
  }
230
632
  });
231
- /** POST /jobs/:id/enable — Enable a job. */
232
- app.post('/jobs/:id/enable', (request, reply) => {
233
- const result = db
234
- .prepare('UPDATE jobs SET enabled = 1 WHERE id = ?')
235
- .run(request.params.id);
236
- if (result.changes === 0) {
237
- reply.code(404);
238
- return { error: 'Job not found' };
239
- }
240
- scheduler.reconcileNow();
241
- return { ok: true };
242
- });
243
- /** POST /jobs/:id/disable — Disable a job. */
244
- app.post('/jobs/:id/disable', (request, reply) => {
245
- const result = db
246
- .prepare('UPDATE jobs SET enabled = 0 WHERE id = ?')
247
- .run(request.params.id);
248
- if (result.changes === 0) {
249
- reply.code(404);
250
- return { error: 'Job not found' };
251
- }
252
- scheduler.reconcileNow();
253
- return { ok: true };
254
- });
255
- /** GET /stats — Aggregate job statistics. */
256
- app.get('/stats', () => {
257
- const totalJobs = db
258
- .prepare('SELECT COUNT(*) as count FROM jobs')
259
- .get();
260
- const runningCount = scheduler.getRunningJobs().length;
261
- const failedCount = scheduler.getFailedRegistrations().length;
262
- const okLastHour = db
263
- .prepare(`SELECT COUNT(*) as count FROM runs
264
- WHERE status = 'ok' AND started_at > datetime('now', '-1 hour')`)
265
- .get();
266
- const errorsLastHour = db
267
- .prepare(`SELECT COUNT(*) as count FROM runs
268
- WHERE status IN ('error', 'timeout') AND started_at > datetime('now', '-1 hour')`)
269
- .get();
270
- return {
271
- totalJobs: totalJobs.count,
272
- running: runningCount,
273
- failedRegistrations: failedCount,
274
- okLastHour: okLastHour.count,
275
- errorsLastHour: errorsLastHour.count,
276
- };
277
- });
278
633
  /** GET /config — Query effective configuration via JSONPath. */
279
634
  const configHandler = createConfigQueryHandler(getConfig);
280
635
  app.get('/config', async (request, reply) => {
@@ -283,10 +638,17 @@ function registerRoutes(app, deps) {
283
638
  });
284
639
  return reply.status(result.status).send(result.body);
285
640
  });
641
+ // Register job CRUD routes (POST, PATCH, DELETE, PATCH enable/disable, PUT script)
642
+ registerJobRoutes(app, { db, scheduler });
643
+ // Register queue and state inspection routes
644
+ registerQueueRoutes(app, { db });
645
+ registerStateRoutes(app, { db });
286
646
  }
287
647
 
288
648
  /**
289
649
  * Fastify HTTP server for runner API. Creates server instance with logging, registers routes, listens on configured port (localhost only).
650
+ *
651
+ * @module
290
652
  */
291
653
  /**
292
654
  * Create and configure the Fastify server. Routes are registered but server is not started.
@@ -311,12 +673,15 @@ function createServer(deps) {
311
673
  db: deps.db,
312
674
  scheduler: deps.scheduler,
313
675
  getConfig: deps.getConfig,
676
+ version: deps.version,
314
677
  });
315
678
  return app;
316
679
  }
317
680
 
318
681
  /**
319
682
  * SQLite connection manager. Creates DB file with parent directories, enables WAL mode for concurrency.
683
+ *
684
+ * @module
320
685
  */
321
686
  /**
322
687
  * Create and configure a SQLite database connection.
@@ -345,6 +710,8 @@ function closeConnection(db) {
345
710
 
346
711
  /**
347
712
  * Database maintenance tasks: run retention pruning and expired state cleanup.
713
+ *
714
+ * @module
348
715
  */
349
716
  /** Delete runs older than the configured retention period. */
350
717
  function pruneOldRuns(db, days, logger) {
@@ -411,6 +778,8 @@ function createMaintenance(db, config, logger) {
411
778
 
412
779
  /**
413
780
  * Schema migration runner. Tracks applied migrations via schema_version table, applies pending migrations idempotently.
781
+ *
782
+ * @module
414
783
  */
415
784
  /** Initial schema migration SQL (embedded to avoid runtime file resolution issues). */
416
785
  const MIGRATION_001 = `
@@ -544,11 +913,16 @@ CREATE TABLE state_items (
544
913
  );
545
914
  CREATE INDEX idx_state_items_ns_key ON state_items(namespace, key);
546
915
  `;
916
+ /** Migration 004: Add source_type column to jobs. */
917
+ const MIGRATION_004 = `
918
+ ALTER TABLE jobs ADD COLUMN source_type TEXT DEFAULT 'path';
919
+ `;
547
920
  /** Registry of all migrations keyed by version number. */
548
921
  const MIGRATIONS = {
549
922
  1: MIGRATION_001,
550
923
  2: MIGRATION_002,
551
924
  3: MIGRATION_003,
925
+ 4: MIGRATION_004,
552
926
  };
553
927
  /**
554
928
  * Run all pending migrations. Creates schema_version table if needed, applies migrations in order.
@@ -579,6 +953,8 @@ function runMigrations(db) {
579
953
 
580
954
  /**
581
955
  * Shared HTTP utility for making POST requests.
956
+ *
957
+ * @module
582
958
  */
583
959
  /** Make an HTTP/HTTPS POST request. Returns the parsed JSON response body. */
584
960
  function httpPost(url, headers, body, timeoutMs = 30000) {
@@ -626,6 +1002,8 @@ function httpPost(url, headers, body, timeoutMs = 30000) {
626
1002
 
627
1003
  /**
628
1004
  * OpenClaw Gateway HTTP client for spawning and monitoring sessions.
1005
+ *
1006
+ * @module
629
1007
  */
630
1008
  /** Make an HTTP POST request to the Gateway /tools/invoke endpoint. */
631
1009
  function invokeGateway(url, token, tool, args, timeoutMs = 30000) {
@@ -692,6 +1070,8 @@ function createGatewayClient(options) {
692
1070
 
693
1071
  /**
694
1072
  * Slack notification module. Sends job completion/failure messages via Slack Web API (chat.postMessage). Falls back gracefully if no token.
1073
+ *
1074
+ * @module
695
1075
  */
696
1076
  /** Post a message to Slack via chat.postMessage API. */
697
1077
  async function postToSlack(token, channel, text) {
@@ -709,21 +1089,21 @@ function createNotifier(config) {
709
1089
  return {
710
1090
  async notifySuccess(jobName, durationMs, channel) {
711
1091
  if (!slackToken) {
712
- console.warn(`No Slack token configured skipping success notification for ${jobName}`);
1092
+ console.warn(`No Slack token configured \u2014 skipping success notification for ${jobName}`);
713
1093
  return;
714
1094
  }
715
1095
  const durationSec = (durationMs / 1000).toFixed(1);
716
- const text = `?? *${jobName}* completed (${durationSec}s)`;
1096
+ const text = `\u2705 *${jobName}* completed (${durationSec}s)`;
717
1097
  await postToSlack(slackToken, channel, text);
718
1098
  },
719
1099
  async notifyFailure(jobName, durationMs, error, channel) {
720
1100
  if (!slackToken) {
721
- console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
1101
+ console.warn(`No Slack token configured \u2014 skipping failure notification for ${jobName}`);
722
1102
  return;
723
1103
  }
724
1104
  const durationSec = (durationMs / 1000).toFixed(1);
725
1105
  const errorMsg = error ? `: ${error}` : '';
726
- const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
1106
+ const text = `\u274c *${jobName}* failed (${durationSec}s)${errorMsg}`;
727
1107
  await postToSlack(slackToken, channel, text);
728
1108
  },
729
1109
  async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
@@ -743,6 +1123,8 @@ function createNotifier(config) {
743
1123
 
744
1124
  /**
745
1125
  * Job executor. Spawns job scripts as child processes, captures output, parses result metadata, enforces timeouts.
1126
+ *
1127
+ * @module
746
1128
  */
747
1129
  /** Ring buffer for capturing last N lines of output. */
748
1130
  class RingBuffer {
@@ -804,14 +1186,23 @@ function resolveCommand(script) {
804
1186
  * Execute a job script as a child process. Captures output, parses metadata, enforces timeout.
805
1187
  */
806
1188
  function executeJob(options) {
807
- const { script, dbPath, jobId, runId, timeoutMs, commandResolver } = options;
1189
+ const { script, dbPath, jobId, runId, timeoutMs, commandResolver, sourceType = 'path', } = options;
808
1190
  const startTime = Date.now();
1191
+ // For inline scripts, write to a temp file and clean up after.
1192
+ let tempFile = null;
1193
+ let effectiveScript = script;
1194
+ if (sourceType === 'inline') {
1195
+ const tempDir = mkdtempSync(join(tmpdir(), 'jr-inline-'));
1196
+ tempFile = join(tempDir, 'inline.js');
1197
+ writeFileSync(tempFile, script);
1198
+ effectiveScript = tempFile;
1199
+ }
809
1200
  return new Promise((resolve) => {
810
1201
  const stdoutBuffer = new RingBuffer(100);
811
1202
  const stderrBuffer = new RingBuffer(100);
812
1203
  const { command, args } = commandResolver
813
- ? commandResolver(script)
814
- : resolveCommand(script);
1204
+ ? commandResolver(effectiveScript)
1205
+ : resolveCommand(effectiveScript);
815
1206
  const child = spawn(command, args, {
816
1207
  env: {
817
1208
  ...process.env,
@@ -847,6 +1238,7 @@ function executeJob(options) {
847
1238
  child.on('close', (exitCode) => {
848
1239
  if (timeoutHandle)
849
1240
  clearTimeout(timeoutHandle);
1241
+ cleanupTempFile(tempFile);
850
1242
  const durationMs = Date.now() - startTime;
851
1243
  const stdoutTail = stdoutBuffer.getAll();
852
1244
  const stderrTail = stderrBuffer.getAll();
@@ -891,6 +1283,7 @@ function executeJob(options) {
891
1283
  child.on('error', (err) => {
892
1284
  if (timeoutHandle)
893
1285
  clearTimeout(timeoutHandle);
1286
+ cleanupTempFile(tempFile);
894
1287
  const durationMs = Date.now() - startTime;
895
1288
  resolve({
896
1289
  status: 'error',
@@ -905,31 +1298,73 @@ function executeJob(options) {
905
1298
  });
906
1299
  });
907
1300
  }
1301
+ /** Remove a temp file created for inline script execution. */
1302
+ function cleanupTempFile(tempFile) {
1303
+ if (!tempFile)
1304
+ return;
1305
+ try {
1306
+ unlinkSync(tempFile);
1307
+ }
1308
+ catch {
1309
+ // Ignore cleanup errors
1310
+ }
1311
+ }
908
1312
 
909
1313
  /**
910
- * Cron registration and reconciliation utilities.
1314
+ * Schedule registration and reconciliation. Supports cron (croner) and RRStack schedule formats via unified setTimeout-based scheduling.
1315
+ *
1316
+ * @module
911
1317
  */
1318
+ /** Maximum setTimeout delay (Node.js limit: ~24.8 days). */
1319
+ const MAX_TIMEOUT_MS = 2_147_483_647;
912
1320
  function createCronRegistry(deps) {
913
1321
  const { db, logger, onScheduledRun } = deps;
914
- const crons = new Map();
915
- const cronSchedules = new Map();
1322
+ const handles = new Map();
1323
+ const scheduleStrings = new Map();
916
1324
  const failedRegistrations = new Set();
917
- function registerCron(job) {
1325
+ /**
1326
+ * Schedule the next fire for a job. Computes next fire time, sets a
1327
+ * setTimeout, and re-arms after each fire.
1328
+ */
1329
+ function scheduleNext(jobId, schedule) {
1330
+ const nextDate = getNextFireTime(schedule);
1331
+ if (!nextDate) {
1332
+ logger.warn({ jobId }, 'No upcoming fire time, job will not fire');
1333
+ return;
1334
+ }
1335
+ const delayMs = Math.max(0, nextDate.getTime() - Date.now());
1336
+ // Node.js setTimeout max is ~24.8 days. If delay exceeds that,
1337
+ // set an intermediate wakeup and re-check.
1338
+ const effectiveDelay = Math.min(delayMs, MAX_TIMEOUT_MS);
1339
+ const isIntermediate = delayMs > MAX_TIMEOUT_MS;
1340
+ const timeout = setTimeout(() => {
1341
+ if (isIntermediate) {
1342
+ // Woke up early just to re-check; schedule again.
1343
+ scheduleNext(jobId, schedule);
1344
+ return;
1345
+ }
1346
+ // Re-read job from DB to get current configuration
1347
+ const currentJob = db
1348
+ .prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
1349
+ .get(jobId);
1350
+ if (!currentJob) {
1351
+ logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
1352
+ return;
1353
+ }
1354
+ onScheduledRun(currentJob);
1355
+ // Re-arm for the next occurrence
1356
+ scheduleNext(jobId, schedule);
1357
+ }, effectiveDelay);
1358
+ handles.set(jobId, {
1359
+ cancel: () => {
1360
+ clearTimeout(timeout);
1361
+ },
1362
+ });
1363
+ }
1364
+ function registerSchedule(job) {
918
1365
  try {
919
- const jobId = job.id;
920
- const cron = new Cron(job.schedule, () => {
921
- // Re-read job from DB to get current configuration
922
- const currentJob = db
923
- .prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
924
- .get(jobId);
925
- if (!currentJob) {
926
- logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
927
- return;
928
- }
929
- onScheduledRun(currentJob);
930
- });
931
- crons.set(job.id, cron);
932
- cronSchedules.set(job.id, job.schedule);
1366
+ scheduleNext(job.id, job.schedule);
1367
+ scheduleStrings.set(job.id, job.schedule);
933
1368
  failedRegistrations.delete(job.id);
934
1369
  logger.info({ jobId: job.id, schedule: job.schedule }, 'Scheduled job');
935
1370
  return true;
@@ -946,39 +1381,39 @@ function createCronRegistry(deps) {
946
1381
  .all();
947
1382
  const enabledById = new Map(enabledJobs.map((j) => [j.id, j]));
948
1383
  // Remove disabled/deleted jobs
949
- for (const [jobId, cron] of crons.entries()) {
1384
+ for (const [jobId, handle] of handles.entries()) {
950
1385
  if (!enabledById.has(jobId)) {
951
- cron.stop();
952
- crons.delete(jobId);
953
- cronSchedules.delete(jobId);
1386
+ handle.cancel();
1387
+ handles.delete(jobId);
1388
+ scheduleStrings.delete(jobId);
954
1389
  }
955
1390
  }
956
1391
  const failedIds = [];
957
1392
  // Add or update enabled jobs
958
1393
  for (const job of enabledJobs) {
959
- const existingCron = crons.get(job.id);
960
- const existingSchedule = cronSchedules.get(job.id);
961
- if (!existingCron) {
962
- if (!registerCron(job))
1394
+ const existingHandle = handles.get(job.id);
1395
+ const existingSchedule = scheduleStrings.get(job.id);
1396
+ if (!existingHandle) {
1397
+ if (!registerSchedule(job))
963
1398
  failedIds.push(job.id);
964
1399
  continue;
965
1400
  }
966
1401
  if (existingSchedule !== job.schedule) {
967
- existingCron.stop();
968
- crons.delete(job.id);
969
- cronSchedules.delete(job.id);
970
- if (!registerCron(job))
1402
+ existingHandle.cancel();
1403
+ handles.delete(job.id);
1404
+ scheduleStrings.delete(job.id);
1405
+ if (!registerSchedule(job))
971
1406
  failedIds.push(job.id);
972
1407
  }
973
1408
  }
974
1409
  return { totalEnabled: enabledJobs.length, failedIds };
975
1410
  }
976
1411
  function stopAll() {
977
- for (const cron of crons.values()) {
978
- cron.stop();
1412
+ for (const handle of handles.values()) {
1413
+ handle.cancel();
979
1414
  }
980
- crons.clear();
981
- cronSchedules.clear();
1415
+ handles.clear();
1416
+ scheduleStrings.clear();
982
1417
  }
983
1418
  return {
984
1419
  reconcile,
@@ -991,6 +1426,8 @@ function createCronRegistry(deps) {
991
1426
 
992
1427
  /**
993
1428
  * Run record repository for managing job execution records.
1429
+ *
1430
+ * @module
994
1431
  */
995
1432
  /** Create a run repository for the given database connection. */
996
1433
  function createRunRepository(db) {
@@ -1012,6 +1449,8 @@ function createRunRepository(db) {
1012
1449
 
1013
1450
  /**
1014
1451
  * Session executor for job type='session'. Spawns OpenClaw Gateway sessions and polls for completion.
1452
+ *
1453
+ * @module
1015
1454
  */
1016
1455
  /** File extensions that indicate a script rather than a prompt. */
1017
1456
  const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ps1', '.cmd', '.bat'];
@@ -1111,7 +1550,9 @@ async function executeSession(options) {
1111
1550
  }
1112
1551
 
1113
1552
  /**
1114
- * Croner-based job scheduler. Loads enabled jobs, creates cron instances, manages execution, respects overlap policies and concurrency limits.
1553
+ * Job scheduler. Loads enabled jobs, registers schedules (cron or rrstack), manages execution, respects overlap policies and concurrency limits.
1554
+ *
1555
+ * @module
1115
1556
  */
1116
1557
  // JobRow is imported from cron-registry
1117
1558
  /**
@@ -1131,7 +1572,7 @@ function createScheduler(deps) {
1131
1572
  let reconcileInterval = null;
1132
1573
  /** Execute a job: create run record, run script, update record, send notifications. */
1133
1574
  async function runJob(job, trigger) {
1134
- const { id, name, script, type, timeout_ms, on_success, on_failure } = job;
1575
+ const { id, name, script, type, timeout_ms, on_success, on_failure, source_type, } = job;
1135
1576
  // Check concurrency limit
1136
1577
  if (runningJobs.size >= config.maxConcurrency) {
1137
1578
  logger.warn({ jobId: id }, 'Max concurrency reached, skipping job');
@@ -1162,6 +1603,7 @@ function createScheduler(deps) {
1162
1603
  jobId: id,
1163
1604
  runId,
1164
1605
  timeoutMs: timeout_ms ?? undefined,
1606
+ sourceType: source_type ?? 'path',
1165
1607
  });
1166
1608
  }
1167
1609
  runRepository.finishRun(runId, result);
@@ -1251,6 +1693,8 @@ function createScheduler(deps) {
1251
1693
 
1252
1694
  /**
1253
1695
  * Main runner orchestrator. Wires up database, scheduler, API server, and handles graceful shutdown on SIGTERM/SIGINT.
1696
+ *
1697
+ * @module
1254
1698
  */
1255
1699
  /**
1256
1700
  * Create the runner. Initializes database, scheduler, API server, and sets up graceful shutdown.
@@ -1315,14 +1759,26 @@ function createRunner(config, deps) {
1315
1759
  });
1316
1760
  scheduler.start();
1317
1761
  logger.info('Scheduler started');
1762
+ // Read package version
1763
+ const pkgVersion = (() => {
1764
+ try {
1765
+ const dir = dirname(fileURLToPath(import.meta.url));
1766
+ const pkg = JSON.parse(readFileSync(resolve(dir, '..', 'package.json'), 'utf8'));
1767
+ return pkg.version ?? 'unknown';
1768
+ }
1769
+ catch {
1770
+ return 'unknown';
1771
+ }
1772
+ })();
1318
1773
  // API server
1319
1774
  server = createServer({
1320
1775
  db,
1321
1776
  scheduler,
1322
1777
  getConfig: () => config,
1778
+ version: pkgVersion,
1323
1779
  loggerConfig: { level: config.log.level, file: config.log.file },
1324
1780
  });
1325
- await server.listen({ port: config.port, host: '127.0.0.1' });
1781
+ await server.listen({ port: config.port, host: config.host });
1326
1782
  logger.info({ port: config.port }, 'API server listening');
1327
1783
  // Graceful shutdown
1328
1784
  const shutdown = async (signal) => {
@@ -1361,6 +1817,8 @@ function createRunner(config, deps) {
1361
1817
 
1362
1818
  /**
1363
1819
  * Queue operations module for runner client. Provides enqueue, dequeue, done, and fail operations.
1820
+ *
1821
+ * @module
1364
1822
  */
1365
1823
  /** Create queue operations for the given database connection. */
1366
1824
  function createQueueOps(db) {
@@ -1463,6 +1921,8 @@ function createQueueOps(db) {
1463
1921
 
1464
1922
  /**
1465
1923
  * State operations module for runner client. Provides scalar state (key-value) and collection state (grouped items).
1924
+ *
1925
+ * @module
1466
1926
  */
1467
1927
  /** Parse TTL string (e.g., '30d', '24h', '60m') into ISO datetime offset from now. */
1468
1928
  function parseTtl(ttl) {
@@ -1568,6 +2028,8 @@ function createCollectionOps(db) {
1568
2028
 
1569
2029
  /**
1570
2030
  * Job client library for runner jobs. Provides state and queue operations. Opens its own DB connection via JR_DB_PATH env var.
2031
+ *
2032
+ * @module
1571
2033
  */
1572
2034
  /**
1573
2035
  * Create a runner client for job scripts. Opens its own DB connection.
@@ -1590,4 +2052,4 @@ function createClient(dbPath) {
1590
2052
  };
1591
2053
  }
1592
2054
 
1593
- export { closeConnection, createClient, createConnection, createGatewayClient, createMaintenance, createNotifier, createRunner, createScheduler, executeJob, executeSession, jobSchema, queueSchema, runMigrations, runSchema, runStatusSchema, runTriggerSchema, runnerConfigSchema };
2055
+ export { closeConnection, createClient, createConnection, createGatewayClient, createMaintenance, createNotifier, createRunner, createScheduler, executeJob, executeSession, getNextFireTime, jobSchema, queueSchema, runMigrations, runSchema, runStatusSchema, runTriggerSchema, runnerConfigSchema, validateSchedule };