@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.
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
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 { z } from 'zod';
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
- * Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, and system stats.
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', async (request, reply) => {
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, deps);
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 skipping success notification for ${jobName}`);
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 = `?? *${jobName}* completed (${durationSec}s)`;
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 skipping failure notification for ${jobName}`);
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 = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
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(script)
641
- : resolveCommand(script);
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
- * Cron registration and reconciliation utilities.
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 crons = new Map();
742
- const cronSchedules = new Map();
1166
+ const handles = new Map();
1167
+ const scheduleStrings = new Map();
743
1168
  const failedRegistrations = new Set();
744
- function registerCron(job) {
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
- const jobId = job.id;
747
- const cron = new Cron(job.schedule, () => {
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, cron] of crons.entries()) {
1228
+ for (const [jobId, handle] of handles.entries()) {
777
1229
  if (!enabledById.has(jobId)) {
778
- cron.stop();
779
- crons.delete(jobId);
780
- cronSchedules.delete(jobId);
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 existingCron = crons.get(job.id);
787
- const existingSchedule = cronSchedules.get(job.id);
788
- if (!existingCron) {
789
- if (!registerCron(job))
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
- existingCron.stop();
795
- crons.delete(job.id);
796
- cronSchedules.delete(job.id);
797
- if (!registerCron(job))
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 cron of crons.values()) {
805
- cron.stop();
1256
+ for (const handle of handles.values()) {
1257
+ handle.cancel();
806
1258
  }
807
- crons.clear();
808
- cronSchedules.clear();
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
- * Croner-based job scheduler. Loads enabled jobs, creates cron instances, manages execution, respects overlap policies and concurrency limits.
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: '127.0.0.1' });
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
- try {
1528
- new CronPattern(options.schedule);
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