@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/mjs/index.js CHANGED
@@ -1,14 +1,17 @@
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
3
  import { pino } from 'pino';
4
4
  import Fastify from 'fastify';
5
- import { dirname, extname } from 'node:path';
5
+ import { createConfigQueryHandler } from '@karmaniverous/jeeves';
6
+ import { RRStack } from '@karmaniverous/rrstack';
7
+ import { Cron } from 'croner';
8
+ import { JSONPath } from 'jsonpath-plus';
9
+ import { dirname, join, extname } from 'node:path';
6
10
  import { DatabaseSync } from 'node:sqlite';
7
11
  import { request as request$1 } from 'node:http';
8
12
  import { request } from 'node:https';
9
13
  import { spawn } from 'node:child_process';
10
- import { Cron } from 'croner';
11
- import { JSONPath } from 'jsonpath-plus';
14
+ import { tmpdir } from 'node:os';
12
15
 
13
16
  /**
14
17
  * Runner configuration schema and types.
@@ -44,6 +47,8 @@ const gatewaySchema = z.object({
44
47
  const runnerConfigSchema = z.object({
45
48
  /** HTTP server port for the runner API. */
46
49
  port: z.number().default(1937),
50
+ /** Bind address for the HTTP server. */
51
+ host: z.string().default('127.0.0.1'),
47
52
  /** Path to SQLite database file. */
48
53
  dbPath: z.string().default('./data/runner.sqlite'),
49
54
  /** Maximum number of concurrent job executions. */
@@ -90,8 +95,8 @@ const jobSchema = z.object({
90
95
  enabled: z.boolean().default(true),
91
96
  /** Optional execution timeout in milliseconds. */
92
97
  timeoutMs: z.number().optional(),
93
- /** Policy for handling overlapping job executions (skip, queue, or allow). */
94
- overlapPolicy: z.enum(['skip', 'queue', 'allow']).default('skip'),
98
+ /** Policy for handling overlapping job executions (skip or allow). */
99
+ overlapPolicy: z.enum(['skip', 'allow']).default('skip'),
95
100
  /** Slack channel ID for failure notifications. */
96
101
  onFailure: z.string().nullable().default(null),
97
102
  /** Slack channel ID for success notifications. */
@@ -170,13 +175,394 @@ const runSchema = z.object({
170
175
  });
171
176
 
172
177
  /**
173
- * Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, and system stats.
178
+ * Schedule parsing, validation, and next-fire-time computation for cron and RRStack formats.
179
+ *
180
+ * @module
181
+ */
182
+ /**
183
+ * Attempt to parse a schedule string as RRStack JSON (non-null, non-array
184
+ * object). Returns the parsed options on success, or null if the string
185
+ * is not a JSON object.
186
+ */
187
+ function tryParseRRStack(schedule) {
188
+ try {
189
+ const parsed = JSON.parse(schedule);
190
+ if (typeof parsed === 'object' &&
191
+ parsed !== null &&
192
+ !Array.isArray(parsed)) {
193
+ return parsed;
194
+ }
195
+ return null;
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ /**
202
+ * Compute the next fire time for a schedule string.
203
+ * Supports cron expressions (via croner) and RRStack JSON (via nextEvent).
204
+ *
205
+ * @param schedule - Cron expression or RRStack JSON string.
206
+ * @returns Next fire time as a Date, or null if none can be determined.
207
+ */
208
+ function getNextFireTime(schedule) {
209
+ const rrOpts = tryParseRRStack(schedule);
210
+ if (rrOpts) {
211
+ const stack = new RRStack(rrOpts);
212
+ const next = stack.nextEvent();
213
+ if (!next)
214
+ return null;
215
+ const unit = stack.timeUnit;
216
+ return new Date(unit === 's' ? next.at * 1000 : next.at);
217
+ }
218
+ const cron = new Cron(schedule);
219
+ return cron.nextRun() ?? null;
220
+ }
221
+ /**
222
+ * Validate a schedule string as either a cron expression or RRStack JSON.
223
+ *
224
+ * @param schedule - Cron expression or RRStack JSON string.
225
+ * @returns Validation result with format on success, or error message on failure.
226
+ */
227
+ function validateSchedule(schedule) {
228
+ const rrOpts = tryParseRRStack(schedule);
229
+ if (rrOpts) {
230
+ try {
231
+ new RRStack(rrOpts);
232
+ return { valid: true, format: 'rrstack' };
233
+ }
234
+ catch (err) {
235
+ return {
236
+ valid: false,
237
+ error: `Invalid RRStack schedule: ${err instanceof Error ? err.message : String(err)}`,
238
+ };
239
+ }
240
+ }
241
+ try {
242
+ new Cron(schedule);
243
+ return { valid: true, format: 'cron' };
244
+ }
245
+ catch (err) {
246
+ return {
247
+ valid: false,
248
+ error: `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`,
249
+ };
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Job CRUD routes: create, update, delete, enable/disable, and script update endpoints.
255
+ *
256
+ * @module
257
+ */
258
+ /** Zod schema for job creation/update request body. */
259
+ const createJobSchema = z.object({
260
+ id: z.string().min(1),
261
+ name: z.string().min(1),
262
+ schedule: z.string().min(1),
263
+ script: z.string().min(1),
264
+ source_type: z.enum(['path', 'inline']).default('path'),
265
+ type: z.enum(['script', 'session']).default('script'),
266
+ timeout_seconds: z.number().positive().optional(),
267
+ overlap_policy: z.enum(['skip', 'allow']).default('skip'),
268
+ enabled: z.boolean().default(true),
269
+ description: z.string().optional(),
270
+ on_failure: z.string().nullable().optional(),
271
+ on_success: z.string().nullable().optional(),
272
+ });
273
+ /** Zod schema for job update (all fields optional except what's set). */
274
+ const updateJobSchema = createJobSchema.omit({ id: true }).partial();
275
+ /** Zod schema for script update request body. */
276
+ const updateScriptSchema = z.object({
277
+ script: z.string().min(1),
278
+ source_type: z.enum(['path', 'inline']).optional(),
279
+ });
280
+ /** Standard error message for missing job resources. */
281
+ const JOB_NOT_FOUND = 'Job not found';
282
+ /**
283
+ * Register job CRUD routes on the Fastify instance.
284
+ */
285
+ function registerJobRoutes(app, deps) {
286
+ const { db, scheduler } = deps;
287
+ /** POST /jobs — Create a new job. */
288
+ app.post('/jobs', (request, reply) => {
289
+ const parsed = createJobSchema.safeParse(request.body);
290
+ if (!parsed.success) {
291
+ reply.code(400);
292
+ return { error: parsed.error.message };
293
+ }
294
+ const data = parsed.data;
295
+ const validation = validateSchedule(data.schedule);
296
+ if (!validation.valid) {
297
+ reply.code(400);
298
+ return { error: validation.error };
299
+ }
300
+ const timeoutMs = data.timeout_seconds ? data.timeout_seconds * 1000 : null;
301
+ db.prepare(`INSERT INTO jobs (id, name, schedule, script, source_type, type, timeout_ms,
302
+ overlap_policy, enabled, description, on_failure, on_success)
303
+ 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);
304
+ scheduler.reconcileNow();
305
+ reply.code(201);
306
+ return { ok: true, id: data.id };
307
+ });
308
+ /** PATCH /jobs/:id — Partial update of an existing job. */
309
+ app.patch('/jobs/:id', (request, reply) => {
310
+ const parsed = updateJobSchema.safeParse(request.body);
311
+ if (!parsed.success) {
312
+ reply.code(400);
313
+ return { error: parsed.error.message };
314
+ }
315
+ const existing = db
316
+ .prepare('SELECT id FROM jobs WHERE id = ?')
317
+ .get(request.params.id);
318
+ if (!existing) {
319
+ reply.code(404);
320
+ return { error: JOB_NOT_FOUND };
321
+ }
322
+ const data = parsed.data;
323
+ if (data.schedule) {
324
+ const validation = validateSchedule(data.schedule);
325
+ if (!validation.valid) {
326
+ reply.code(400);
327
+ return { error: validation.error };
328
+ }
329
+ }
330
+ /** Map input field → DB column + value transform. */
331
+ const fieldMap = [
332
+ { input: 'name', column: 'name' },
333
+ { input: 'schedule', column: 'schedule' },
334
+ { input: 'script', column: 'script' },
335
+ { input: 'source_type', column: 'source_type' },
336
+ { input: 'type', column: 'type' },
337
+ {
338
+ input: 'timeout_seconds',
339
+ column: 'timeout_ms',
340
+ transform: (v) => v * 1000,
341
+ },
342
+ { input: 'overlap_policy', column: 'overlap_policy' },
343
+ {
344
+ input: 'enabled',
345
+ column: 'enabled',
346
+ transform: (v) => (v ? 1 : 0),
347
+ },
348
+ { input: 'description', column: 'description' },
349
+ { input: 'on_failure', column: 'on_failure' },
350
+ { input: 'on_success', column: 'on_success' },
351
+ ];
352
+ const sets = [];
353
+ const values = [];
354
+ for (const { input, column, transform } of fieldMap) {
355
+ if (data[input] !== undefined) {
356
+ sets.push(`${column} = ?`);
357
+ values.push(transform ? transform(data[input]) : data[input]);
358
+ }
359
+ }
360
+ if (sets.length > 0) {
361
+ sets.push("updated_at = datetime('now')");
362
+ values.push(request.params.id);
363
+ db.prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values);
364
+ }
365
+ scheduler.reconcileNow();
366
+ return { ok: true };
367
+ });
368
+ /** DELETE /jobs/:id — Delete a job and its runs. */
369
+ app.delete('/jobs/:id', (request, reply) => {
370
+ db.prepare('DELETE FROM runs WHERE job_id = ?').run(request.params.id);
371
+ const result = db
372
+ .prepare('DELETE FROM jobs WHERE id = ?')
373
+ .run(request.params.id);
374
+ if (result.changes === 0) {
375
+ reply.code(404);
376
+ return { error: JOB_NOT_FOUND };
377
+ }
378
+ scheduler.reconcileNow();
379
+ return { ok: true };
380
+ });
381
+ /** Register a PATCH toggle endpoint (enable or disable). */
382
+ function registerToggle(path, enabledValue) {
383
+ app.patch(path, (request, reply) => {
384
+ const result = db
385
+ .prepare('UPDATE jobs SET enabled = ? WHERE id = ?')
386
+ .run(enabledValue, request.params.id);
387
+ if (result.changes === 0) {
388
+ reply.code(404);
389
+ return { error: JOB_NOT_FOUND };
390
+ }
391
+ scheduler.reconcileNow();
392
+ return { ok: true };
393
+ });
394
+ }
395
+ registerToggle('/jobs/:id/enable', 1);
396
+ registerToggle('/jobs/:id/disable', 0);
397
+ /** PUT /jobs/:id/script — Update job script and source_type. */
398
+ app.put('/jobs/:id/script', (request, reply) => {
399
+ const parsed = updateScriptSchema.safeParse(request.body);
400
+ if (!parsed.success) {
401
+ reply.code(400);
402
+ return { error: parsed.error.message };
403
+ }
404
+ const data = parsed.data;
405
+ const result = data.source_type
406
+ ? db
407
+ .prepare('UPDATE jobs SET script = ?, source_type = ? WHERE id = ?')
408
+ .run(data.script, data.source_type, request.params.id)
409
+ : db
410
+ .prepare('UPDATE jobs SET script = ? WHERE id = ?')
411
+ .run(data.script, request.params.id);
412
+ if (result.changes === 0) {
413
+ reply.code(404);
414
+ return { error: JOB_NOT_FOUND };
415
+ }
416
+ scheduler.reconcileNow();
417
+ return { ok: true };
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Queue inspection routes: list queues, queue status, and peek at pending items.
423
+ *
424
+ * @module
425
+ */
426
+ /**
427
+ * Register queue inspection routes on the Fastify instance.
428
+ */
429
+ function registerQueueRoutes(app, deps) {
430
+ const { db } = deps;
431
+ /** GET /queues — List all distinct queues that have items. */
432
+ app.get('/queues', () => {
433
+ const rows = db
434
+ .prepare('SELECT DISTINCT queue_id FROM queue_items')
435
+ .all();
436
+ return { queues: rows.map((r) => r.queue_id) };
437
+ });
438
+ /** GET /queues/:name/status — Queue depth, claimed count, failed count, oldest age. */
439
+ app.get('/queues/:name/status', (request) => {
440
+ const row = db
441
+ .prepare(`SELECT
442
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS depth,
443
+ SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) AS claimed,
444
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
445
+ MIN(CASE WHEN status = 'pending' THEN created_at END) AS oldest
446
+ FROM queue_items
447
+ WHERE queue_id = ?`)
448
+ .get(request.params.name);
449
+ return {
450
+ depth: row.depth ?? 0,
451
+ claimedCount: row.claimed ?? 0,
452
+ failedCount: row.failed ?? 0,
453
+ oldestAge: row.oldest
454
+ ? Date.now() - new Date(row.oldest).getTime()
455
+ : null,
456
+ };
457
+ });
458
+ /** GET /queues/:name/peek — Non-claiming read of pending items. */
459
+ app.get('/queues/:name/peek', (request) => {
460
+ const name = request.params.name;
461
+ const limit = parseInt(request.query.limit ?? '10', 10);
462
+ const items = db
463
+ .prepare(`SELECT id, payload, priority, created_at FROM queue_items
464
+ WHERE queue_id = ? AND status = 'pending'
465
+ ORDER BY priority DESC, created_at
466
+ LIMIT ?`)
467
+ .all(name, limit);
468
+ return {
469
+ items: items.map((item) => ({
470
+ id: item.id,
471
+ payload: JSON.parse(item.payload),
472
+ priority: item.priority,
473
+ createdAt: item.created_at,
474
+ })),
475
+ };
476
+ });
477
+ }
478
+
479
+ /**
480
+ * State inspection routes: list namespaces, read scalar state, and read collection items.
481
+ *
482
+ * @module
483
+ */
484
+ /**
485
+ * Register state inspection routes on the Fastify instance.
486
+ */
487
+ function registerStateRoutes(app, deps) {
488
+ const { db } = deps;
489
+ /** GET /state — List all distinct namespaces. */
490
+ app.get('/state', () => {
491
+ const rows = db
492
+ .prepare('SELECT DISTINCT namespace FROM state')
493
+ .all();
494
+ return { namespaces: rows.map((r) => r.namespace) };
495
+ });
496
+ /** GET /state/:namespace — Materialise all scalar state as key-value map. */
497
+ app.get('/state/:namespace', (request) => {
498
+ const { namespace } = request.params;
499
+ const rows = db
500
+ .prepare(`SELECT key, value FROM state
501
+ WHERE namespace = ?
502
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`)
503
+ .all(namespace);
504
+ const state = {};
505
+ for (const row of rows) {
506
+ state[row.key] = row.value;
507
+ }
508
+ if (request.query.path) {
509
+ const result = JSONPath({
510
+ path: request.query.path,
511
+ json: state,
512
+ });
513
+ return { result, count: Array.isArray(result) ? result.length : 1 };
514
+ }
515
+ return state;
516
+ });
517
+ /** GET /state/:namespace/:key — Read collection items for a state key. */
518
+ app.get('/state/:namespace/:key', (request) => {
519
+ const { namespace, key } = request.params;
520
+ const limit = parseInt(request.query.limit ?? '100', 10);
521
+ const order = request.query.order === 'asc' ? 'ASC' : 'DESC';
522
+ // First check scalar state value
523
+ const scalar = db
524
+ .prepare(`SELECT value FROM state
525
+ WHERE namespace = ? AND key = ?
526
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`)
527
+ .get(namespace, key);
528
+ // Get collection items
529
+ const items = db
530
+ .prepare(`SELECT item_key, value, updated_at FROM state_items
531
+ WHERE namespace = ? AND key = ?
532
+ ORDER BY updated_at ${order}
533
+ LIMIT ?`)
534
+ .all(namespace, key, limit);
535
+ const mappedItems = items.map((item) => ({
536
+ itemKey: item.item_key,
537
+ value: item.value,
538
+ updatedAt: item.updated_at,
539
+ }));
540
+ const body = {
541
+ value: scalar?.value ?? null,
542
+ items: mappedItems,
543
+ count: mappedItems.length,
544
+ };
545
+ if (request.query.path) {
546
+ const result = JSONPath({
547
+ path: request.query.path,
548
+ json: body,
549
+ });
550
+ return { result, count: Array.isArray(result) ? result.length : 1 };
551
+ }
552
+ return body;
553
+ });
554
+ }
555
+
556
+ /**
557
+ * Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, system stats, and config queries.
558
+ *
559
+ * @module routes
174
560
  */
175
561
  /**
176
562
  * Register all API routes on the Fastify instance.
177
563
  */
178
564
  function registerRoutes(app, deps) {
179
- const { db, scheduler } = deps;
565
+ const { db, scheduler, getConfig } = deps;
180
566
  /** GET /health — Health check. */
181
567
  app.get('/health', () => {
182
568
  return {
@@ -196,7 +582,7 @@ function registerRoutes(app, deps) {
196
582
  return { jobs: rows };
197
583
  });
198
584
  /** GET /jobs/:id — Single job detail. */
199
- app.get('/jobs/:id', async (request, reply) => {
585
+ app.get('/jobs/:id', (request, reply) => {
200
586
  const job = db
201
587
  .prepare('SELECT * FROM jobs WHERE id = ?')
202
588
  .get(request.params.id);
@@ -225,30 +611,6 @@ function registerRoutes(app, deps) {
225
611
  return { error: err instanceof Error ? err.message : 'Unknown error' };
226
612
  }
227
613
  });
228
- /** POST /jobs/:id/enable — Enable a job. */
229
- app.post('/jobs/:id/enable', (request, reply) => {
230
- const result = db
231
- .prepare('UPDATE jobs SET enabled = 1 WHERE id = ?')
232
- .run(request.params.id);
233
- if (result.changes === 0) {
234
- reply.code(404);
235
- return { error: 'Job not found' };
236
- }
237
- scheduler.reconcileNow();
238
- return { ok: true };
239
- });
240
- /** POST /jobs/:id/disable — Disable a job. */
241
- app.post('/jobs/:id/disable', (request, reply) => {
242
- const result = db
243
- .prepare('UPDATE jobs SET enabled = 0 WHERE id = ?')
244
- .run(request.params.id);
245
- if (result.changes === 0) {
246
- reply.code(404);
247
- return { error: 'Job not found' };
248
- }
249
- scheduler.reconcileNow();
250
- return { ok: true };
251
- });
252
614
  /** GET /stats — Aggregate job statistics. */
253
615
  app.get('/stats', () => {
254
616
  const totalJobs = db
@@ -272,10 +634,25 @@ function registerRoutes(app, deps) {
272
634
  errorsLastHour: errorsLastHour.count,
273
635
  };
274
636
  });
637
+ /** GET /config — Query effective configuration via JSONPath. */
638
+ const configHandler = createConfigQueryHandler(getConfig);
639
+ app.get('/config', async (request, reply) => {
640
+ const result = await configHandler({
641
+ path: request.query.path,
642
+ });
643
+ return reply.status(result.status).send(result.body);
644
+ });
645
+ // Register job CRUD routes (POST, PATCH, DELETE, PATCH enable/disable, PUT script)
646
+ registerJobRoutes(app, { db, scheduler });
647
+ // Register queue and state inspection routes
648
+ registerQueueRoutes(app, { db });
649
+ registerStateRoutes(app, { db });
275
650
  }
276
651
 
277
652
  /**
278
653
  * Fastify HTTP server for runner API. Creates server instance with logging, registers routes, listens on configured port (localhost only).
654
+ *
655
+ * @module
279
656
  */
280
657
  /**
281
658
  * Create and configure the Fastify server. Routes are registered but server is not started.
@@ -296,12 +673,18 @@ function createServer(deps) {
296
673
  }
297
674
  : false,
298
675
  });
299
- registerRoutes(app, deps);
676
+ registerRoutes(app, {
677
+ db: deps.db,
678
+ scheduler: deps.scheduler,
679
+ getConfig: deps.getConfig,
680
+ });
300
681
  return app;
301
682
  }
302
683
 
303
684
  /**
304
685
  * SQLite connection manager. Creates DB file with parent directories, enables WAL mode for concurrency.
686
+ *
687
+ * @module
305
688
  */
306
689
  /**
307
690
  * Create and configure a SQLite database connection.
@@ -330,6 +713,8 @@ function closeConnection(db) {
330
713
 
331
714
  /**
332
715
  * Database maintenance tasks: run retention pruning and expired state cleanup.
716
+ *
717
+ * @module
333
718
  */
334
719
  /** Delete runs older than the configured retention period. */
335
720
  function pruneOldRuns(db, days, logger) {
@@ -396,6 +781,8 @@ function createMaintenance(db, config, logger) {
396
781
 
397
782
  /**
398
783
  * Schema migration runner. Tracks applied migrations via schema_version table, applies pending migrations idempotently.
784
+ *
785
+ * @module
399
786
  */
400
787
  /** Initial schema migration SQL (embedded to avoid runtime file resolution issues). */
401
788
  const MIGRATION_001 = `
@@ -529,11 +916,16 @@ CREATE TABLE state_items (
529
916
  );
530
917
  CREATE INDEX idx_state_items_ns_key ON state_items(namespace, key);
531
918
  `;
919
+ /** Migration 004: Add source_type column to jobs. */
920
+ const MIGRATION_004 = `
921
+ ALTER TABLE jobs ADD COLUMN source_type TEXT DEFAULT 'path';
922
+ `;
532
923
  /** Registry of all migrations keyed by version number. */
533
924
  const MIGRATIONS = {
534
925
  1: MIGRATION_001,
535
926
  2: MIGRATION_002,
536
927
  3: MIGRATION_003,
928
+ 4: MIGRATION_004,
537
929
  };
538
930
  /**
539
931
  * Run all pending migrations. Creates schema_version table if needed, applies migrations in order.
@@ -564,6 +956,8 @@ function runMigrations(db) {
564
956
 
565
957
  /**
566
958
  * Shared HTTP utility for making POST requests.
959
+ *
960
+ * @module
567
961
  */
568
962
  /** Make an HTTP/HTTPS POST request. Returns the parsed JSON response body. */
569
963
  function httpPost(url, headers, body, timeoutMs = 30000) {
@@ -611,6 +1005,8 @@ function httpPost(url, headers, body, timeoutMs = 30000) {
611
1005
 
612
1006
  /**
613
1007
  * OpenClaw Gateway HTTP client for spawning and monitoring sessions.
1008
+ *
1009
+ * @module
614
1010
  */
615
1011
  /** Make an HTTP POST request to the Gateway /tools/invoke endpoint. */
616
1012
  function invokeGateway(url, token, tool, args, timeoutMs = 30000) {
@@ -677,6 +1073,8 @@ function createGatewayClient(options) {
677
1073
 
678
1074
  /**
679
1075
  * Slack notification module. Sends job completion/failure messages via Slack Web API (chat.postMessage). Falls back gracefully if no token.
1076
+ *
1077
+ * @module
680
1078
  */
681
1079
  /** Post a message to Slack via chat.postMessage API. */
682
1080
  async function postToSlack(token, channel, text) {
@@ -694,21 +1092,21 @@ function createNotifier(config) {
694
1092
  return {
695
1093
  async notifySuccess(jobName, durationMs, channel) {
696
1094
  if (!slackToken) {
697
- console.warn(`No Slack token configured skipping success notification for ${jobName}`);
1095
+ console.warn(`No Slack token configured \u2014 skipping success notification for ${jobName}`);
698
1096
  return;
699
1097
  }
700
1098
  const durationSec = (durationMs / 1000).toFixed(1);
701
- const text = `?? *${jobName}* completed (${durationSec}s)`;
1099
+ const text = `\u2705 *${jobName}* completed (${durationSec}s)`;
702
1100
  await postToSlack(slackToken, channel, text);
703
1101
  },
704
1102
  async notifyFailure(jobName, durationMs, error, channel) {
705
1103
  if (!slackToken) {
706
- console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
1104
+ console.warn(`No Slack token configured \u2014 skipping failure notification for ${jobName}`);
707
1105
  return;
708
1106
  }
709
1107
  const durationSec = (durationMs / 1000).toFixed(1);
710
1108
  const errorMsg = error ? `: ${error}` : '';
711
- const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
1109
+ const text = `\u274c *${jobName}* failed (${durationSec}s)${errorMsg}`;
712
1110
  await postToSlack(slackToken, channel, text);
713
1111
  },
714
1112
  async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
@@ -728,6 +1126,8 @@ function createNotifier(config) {
728
1126
 
729
1127
  /**
730
1128
  * Job executor. Spawns job scripts as child processes, captures output, parses result metadata, enforces timeouts.
1129
+ *
1130
+ * @module
731
1131
  */
732
1132
  /** Ring buffer for capturing last N lines of output. */
733
1133
  class RingBuffer {
@@ -789,14 +1189,23 @@ function resolveCommand(script) {
789
1189
  * Execute a job script as a child process. Captures output, parses metadata, enforces timeout.
790
1190
  */
791
1191
  function executeJob(options) {
792
- const { script, dbPath, jobId, runId, timeoutMs, commandResolver } = options;
1192
+ const { script, dbPath, jobId, runId, timeoutMs, commandResolver, sourceType = 'path', } = options;
793
1193
  const startTime = Date.now();
1194
+ // For inline scripts, write to a temp file and clean up after.
1195
+ let tempFile = null;
1196
+ let effectiveScript = script;
1197
+ if (sourceType === 'inline') {
1198
+ const tempDir = mkdtempSync(join(tmpdir(), 'jr-inline-'));
1199
+ tempFile = join(tempDir, 'inline.js');
1200
+ writeFileSync(tempFile, script);
1201
+ effectiveScript = tempFile;
1202
+ }
794
1203
  return new Promise((resolve) => {
795
1204
  const stdoutBuffer = new RingBuffer(100);
796
1205
  const stderrBuffer = new RingBuffer(100);
797
1206
  const { command, args } = commandResolver
798
- ? commandResolver(script)
799
- : resolveCommand(script);
1207
+ ? commandResolver(effectiveScript)
1208
+ : resolveCommand(effectiveScript);
800
1209
  const child = spawn(command, args, {
801
1210
  env: {
802
1211
  ...process.env,
@@ -832,6 +1241,7 @@ function executeJob(options) {
832
1241
  child.on('close', (exitCode) => {
833
1242
  if (timeoutHandle)
834
1243
  clearTimeout(timeoutHandle);
1244
+ cleanupTempFile(tempFile);
835
1245
  const durationMs = Date.now() - startTime;
836
1246
  const stdoutTail = stdoutBuffer.getAll();
837
1247
  const stderrTail = stderrBuffer.getAll();
@@ -876,6 +1286,7 @@ function executeJob(options) {
876
1286
  child.on('error', (err) => {
877
1287
  if (timeoutHandle)
878
1288
  clearTimeout(timeoutHandle);
1289
+ cleanupTempFile(tempFile);
879
1290
  const durationMs = Date.now() - startTime;
880
1291
  resolve({
881
1292
  status: 'error',
@@ -890,31 +1301,73 @@ function executeJob(options) {
890
1301
  });
891
1302
  });
892
1303
  }
1304
+ /** Remove a temp file created for inline script execution. */
1305
+ function cleanupTempFile(tempFile) {
1306
+ if (!tempFile)
1307
+ return;
1308
+ try {
1309
+ unlinkSync(tempFile);
1310
+ }
1311
+ catch {
1312
+ // Ignore cleanup errors
1313
+ }
1314
+ }
893
1315
 
894
1316
  /**
895
- * Cron registration and reconciliation utilities.
1317
+ * Schedule registration and reconciliation. Supports cron (croner) and RRStack schedule formats via unified setTimeout-based scheduling.
1318
+ *
1319
+ * @module
896
1320
  */
1321
+ /** Maximum setTimeout delay (Node.js limit: ~24.8 days). */
1322
+ const MAX_TIMEOUT_MS = 2_147_483_647;
897
1323
  function createCronRegistry(deps) {
898
1324
  const { db, logger, onScheduledRun } = deps;
899
- const crons = new Map();
900
- const cronSchedules = new Map();
1325
+ const handles = new Map();
1326
+ const scheduleStrings = new Map();
901
1327
  const failedRegistrations = new Set();
902
- function registerCron(job) {
1328
+ /**
1329
+ * Schedule the next fire for a job. Computes next fire time, sets a
1330
+ * setTimeout, and re-arms after each fire.
1331
+ */
1332
+ function scheduleNext(jobId, schedule) {
1333
+ const nextDate = getNextFireTime(schedule);
1334
+ if (!nextDate) {
1335
+ logger.warn({ jobId }, 'No upcoming fire time, job will not fire');
1336
+ return;
1337
+ }
1338
+ const delayMs = Math.max(0, nextDate.getTime() - Date.now());
1339
+ // Node.js setTimeout max is ~24.8 days. If delay exceeds that,
1340
+ // set an intermediate wakeup and re-check.
1341
+ const effectiveDelay = Math.min(delayMs, MAX_TIMEOUT_MS);
1342
+ const isIntermediate = delayMs > MAX_TIMEOUT_MS;
1343
+ const timeout = setTimeout(() => {
1344
+ if (isIntermediate) {
1345
+ // Woke up early just to re-check; schedule again.
1346
+ scheduleNext(jobId, schedule);
1347
+ return;
1348
+ }
1349
+ // Re-read job from DB to get current configuration
1350
+ const currentJob = db
1351
+ .prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
1352
+ .get(jobId);
1353
+ if (!currentJob) {
1354
+ logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
1355
+ return;
1356
+ }
1357
+ onScheduledRun(currentJob);
1358
+ // Re-arm for the next occurrence
1359
+ scheduleNext(jobId, schedule);
1360
+ }, effectiveDelay);
1361
+ handles.set(jobId, {
1362
+ cancel: () => {
1363
+ clearTimeout(timeout);
1364
+ },
1365
+ });
1366
+ }
1367
+ function registerSchedule(job) {
903
1368
  try {
904
- const jobId = job.id;
905
- const cron = new Cron(job.schedule, () => {
906
- // Re-read job from DB to get current configuration
907
- const currentJob = db
908
- .prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
909
- .get(jobId);
910
- if (!currentJob) {
911
- logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
912
- return;
913
- }
914
- onScheduledRun(currentJob);
915
- });
916
- crons.set(job.id, cron);
917
- cronSchedules.set(job.id, job.schedule);
1369
+ scheduleNext(job.id, job.schedule);
1370
+ scheduleStrings.set(job.id, job.schedule);
918
1371
  failedRegistrations.delete(job.id);
919
1372
  logger.info({ jobId: job.id, schedule: job.schedule }, 'Scheduled job');
920
1373
  return true;
@@ -931,39 +1384,39 @@ function createCronRegistry(deps) {
931
1384
  .all();
932
1385
  const enabledById = new Map(enabledJobs.map((j) => [j.id, j]));
933
1386
  // Remove disabled/deleted jobs
934
- for (const [jobId, cron] of crons.entries()) {
1387
+ for (const [jobId, handle] of handles.entries()) {
935
1388
  if (!enabledById.has(jobId)) {
936
- cron.stop();
937
- crons.delete(jobId);
938
- cronSchedules.delete(jobId);
1389
+ handle.cancel();
1390
+ handles.delete(jobId);
1391
+ scheduleStrings.delete(jobId);
939
1392
  }
940
1393
  }
941
1394
  const failedIds = [];
942
1395
  // Add or update enabled jobs
943
1396
  for (const job of enabledJobs) {
944
- const existingCron = crons.get(job.id);
945
- const existingSchedule = cronSchedules.get(job.id);
946
- if (!existingCron) {
947
- if (!registerCron(job))
1397
+ const existingHandle = handles.get(job.id);
1398
+ const existingSchedule = scheduleStrings.get(job.id);
1399
+ if (!existingHandle) {
1400
+ if (!registerSchedule(job))
948
1401
  failedIds.push(job.id);
949
1402
  continue;
950
1403
  }
951
1404
  if (existingSchedule !== job.schedule) {
952
- existingCron.stop();
953
- crons.delete(job.id);
954
- cronSchedules.delete(job.id);
955
- if (!registerCron(job))
1405
+ existingHandle.cancel();
1406
+ handles.delete(job.id);
1407
+ scheduleStrings.delete(job.id);
1408
+ if (!registerSchedule(job))
956
1409
  failedIds.push(job.id);
957
1410
  }
958
1411
  }
959
1412
  return { totalEnabled: enabledJobs.length, failedIds };
960
1413
  }
961
1414
  function stopAll() {
962
- for (const cron of crons.values()) {
963
- cron.stop();
1415
+ for (const handle of handles.values()) {
1416
+ handle.cancel();
964
1417
  }
965
- crons.clear();
966
- cronSchedules.clear();
1418
+ handles.clear();
1419
+ scheduleStrings.clear();
967
1420
  }
968
1421
  return {
969
1422
  reconcile,
@@ -976,6 +1429,8 @@ function createCronRegistry(deps) {
976
1429
 
977
1430
  /**
978
1431
  * Run record repository for managing job execution records.
1432
+ *
1433
+ * @module
979
1434
  */
980
1435
  /** Create a run repository for the given database connection. */
981
1436
  function createRunRepository(db) {
@@ -997,6 +1452,8 @@ function createRunRepository(db) {
997
1452
 
998
1453
  /**
999
1454
  * Session executor for job type='session'. Spawns OpenClaw Gateway sessions and polls for completion.
1455
+ *
1456
+ * @module
1000
1457
  */
1001
1458
  /** File extensions that indicate a script rather than a prompt. */
1002
1459
  const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ps1', '.cmd', '.bat'];
@@ -1096,7 +1553,9 @@ async function executeSession(options) {
1096
1553
  }
1097
1554
 
1098
1555
  /**
1099
- * Croner-based job scheduler. Loads enabled jobs, creates cron instances, manages execution, respects overlap policies and concurrency limits.
1556
+ * Job scheduler. Loads enabled jobs, registers schedules (cron or rrstack), manages execution, respects overlap policies and concurrency limits.
1557
+ *
1558
+ * @module
1100
1559
  */
1101
1560
  // JobRow is imported from cron-registry
1102
1561
  /**
@@ -1116,7 +1575,7 @@ function createScheduler(deps) {
1116
1575
  let reconcileInterval = null;
1117
1576
  /** Execute a job: create run record, run script, update record, send notifications. */
1118
1577
  async function runJob(job, trigger) {
1119
- const { id, name, script, type, timeout_ms, on_success, on_failure } = job;
1578
+ const { id, name, script, type, timeout_ms, on_success, on_failure, source_type, } = job;
1120
1579
  // Check concurrency limit
1121
1580
  if (runningJobs.size >= config.maxConcurrency) {
1122
1581
  logger.warn({ jobId: id }, 'Max concurrency reached, skipping job');
@@ -1147,6 +1606,7 @@ function createScheduler(deps) {
1147
1606
  jobId: id,
1148
1607
  runId,
1149
1608
  timeoutMs: timeout_ms ?? undefined,
1609
+ sourceType: source_type ?? 'path',
1150
1610
  });
1151
1611
  }
1152
1612
  runRepository.finishRun(runId, result);
@@ -1236,6 +1696,8 @@ function createScheduler(deps) {
1236
1696
 
1237
1697
  /**
1238
1698
  * Main runner orchestrator. Wires up database, scheduler, API server, and handles graceful shutdown on SIGTERM/SIGINT.
1699
+ *
1700
+ * @module
1239
1701
  */
1240
1702
  /**
1241
1703
  * Create the runner. Initializes database, scheduler, API server, and sets up graceful shutdown.
@@ -1304,9 +1766,10 @@ function createRunner(config, deps) {
1304
1766
  server = createServer({
1305
1767
  db,
1306
1768
  scheduler,
1769
+ getConfig: () => config,
1307
1770
  loggerConfig: { level: config.log.level, file: config.log.file },
1308
1771
  });
1309
- await server.listen({ port: config.port, host: '127.0.0.1' });
1772
+ await server.listen({ port: config.port, host: config.host });
1310
1773
  logger.info({ port: config.port }, 'API server listening');
1311
1774
  // Graceful shutdown
1312
1775
  const shutdown = async (signal) => {
@@ -1345,6 +1808,8 @@ function createRunner(config, deps) {
1345
1808
 
1346
1809
  /**
1347
1810
  * Queue operations module for runner client. Provides enqueue, dequeue, done, and fail operations.
1811
+ *
1812
+ * @module
1348
1813
  */
1349
1814
  /** Create queue operations for the given database connection. */
1350
1815
  function createQueueOps(db) {
@@ -1447,6 +1912,8 @@ function createQueueOps(db) {
1447
1912
 
1448
1913
  /**
1449
1914
  * State operations module for runner client. Provides scalar state (key-value) and collection state (grouped items).
1915
+ *
1916
+ * @module
1450
1917
  */
1451
1918
  /** Parse TTL string (e.g., '30d', '24h', '60m') into ISO datetime offset from now. */
1452
1919
  function parseTtl(ttl) {
@@ -1552,6 +2019,8 @@ function createCollectionOps(db) {
1552
2019
 
1553
2020
  /**
1554
2021
  * Job client library for runner jobs. Provides state and queue operations. Opens its own DB connection via JR_DB_PATH env var.
2022
+ *
2023
+ * @module
1555
2024
  */
1556
2025
  /**
1557
2026
  * Create a runner client for job scripts. Opens its own DB connection.
@@ -1574,4 +2043,4 @@ function createClient(dbPath) {
1574
2043
  };
1575
2044
  }
1576
2045
 
1577
- export { closeConnection, createClient, createConnection, createGatewayClient, createMaintenance, createNotifier, createRunner, createScheduler, executeJob, executeSession, jobSchema, queueSchema, runMigrations, runSchema, runStatusSchema, runTriggerSchema, runnerConfigSchema };
2046
+ export { closeConnection, createClient, createConnection, createGatewayClient, createMaintenance, createNotifier, createRunner, createScheduler, executeJob, executeSession, getNextFireTime, jobSchema, queueSchema, runMigrations, runSchema, runStatusSchema, runTriggerSchema, runnerConfigSchema, validateSchedule };