@karmaniverous/jeeves-runner 0.5.0 → 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,19 +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';
9
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';
10
13
  import { request as request$1 } from 'node:http';
11
14
  import { request } from 'node:https';
12
15
  import { spawn } from 'node:child_process';
13
- import { z } from 'zod';
16
+ import { tmpdir } from 'node:os';
14
17
 
15
18
  /**
16
19
  * SQLite connection manager. Creates DB file with parent directories, enables WAL mode for concurrency.
20
+ *
21
+ * @module
17
22
  */
18
23
  /**
19
24
  * Create and configure a SQLite database connection.
@@ -42,6 +47,8 @@ function closeConnection(db) {
42
47
 
43
48
  /**
44
49
  * Schema migration runner. Tracks applied migrations via schema_version table, applies pending migrations idempotently.
50
+ *
51
+ * @module
45
52
  */
46
53
  /** Initial schema migration SQL (embedded to avoid runtime file resolution issues). */
47
54
  const MIGRATION_001 = `
@@ -175,11 +182,16 @@ CREATE TABLE state_items (
175
182
  );
176
183
  CREATE INDEX idx_state_items_ns_key ON state_items(namespace, key);
177
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
+ `;
178
189
  /** Registry of all migrations keyed by version number. */
179
190
  const MIGRATIONS = {
180
191
  1: MIGRATION_001,
181
192
  2: MIGRATION_002,
182
193
  3: MIGRATION_003,
194
+ 4: MIGRATION_004,
183
195
  };
184
196
  /**
185
197
  * Run all pending migrations. Creates schema_version table if needed, applies migrations in order.
@@ -208,6 +220,385 @@ function runMigrations(db) {
208
220
  }
209
221
  }
210
222
 
223
+ /**
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
+
211
602
  /**
212
603
  * Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, system stats, and config queries.
213
604
  *
@@ -237,7 +628,7 @@ function registerRoutes(app, deps) {
237
628
  return { jobs: rows };
238
629
  });
239
630
  /** GET /jobs/:id — Single job detail. */
240
- app.get('/jobs/:id', async (request, reply) => {
631
+ app.get('/jobs/:id', (request, reply) => {
241
632
  const job = db
242
633
  .prepare('SELECT * FROM jobs WHERE id = ?')
243
634
  .get(request.params.id);
@@ -266,30 +657,6 @@ function registerRoutes(app, deps) {
266
657
  return { error: err instanceof Error ? err.message : 'Unknown error' };
267
658
  }
268
659
  });
269
- /** POST /jobs/:id/enable — Enable a job. */
270
- app.post('/jobs/:id/enable', (request, reply) => {
271
- const result = db
272
- .prepare('UPDATE jobs SET enabled = 1 WHERE id = ?')
273
- .run(request.params.id);
274
- if (result.changes === 0) {
275
- reply.code(404);
276
- return { error: 'Job not found' };
277
- }
278
- scheduler.reconcileNow();
279
- return { ok: true };
280
- });
281
- /** POST /jobs/:id/disable — Disable a job. */
282
- app.post('/jobs/:id/disable', (request, reply) => {
283
- const result = db
284
- .prepare('UPDATE jobs SET enabled = 0 WHERE id = ?')
285
- .run(request.params.id);
286
- if (result.changes === 0) {
287
- reply.code(404);
288
- return { error: 'Job not found' };
289
- }
290
- scheduler.reconcileNow();
291
- return { ok: true };
292
- });
293
660
  /** GET /stats — Aggregate job statistics. */
294
661
  app.get('/stats', () => {
295
662
  const totalJobs = db
@@ -321,10 +688,17 @@ function registerRoutes(app, deps) {
321
688
  });
322
689
  return reply.status(result.status).send(result.body);
323
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 });
324
696
  }
325
697
 
326
698
  /**
327
699
  * Fastify HTTP server for runner API. Creates server instance with logging, registers routes, listens on configured port (localhost only).
700
+ *
701
+ * @module
328
702
  */
329
703
  /**
330
704
  * Create and configure the Fastify server. Routes are registered but server is not started.
@@ -355,6 +729,8 @@ function createServer(deps) {
355
729
 
356
730
  /**
357
731
  * Database maintenance tasks: run retention pruning and expired state cleanup.
732
+ *
733
+ * @module
358
734
  */
359
735
  /** Delete runs older than the configured retention period. */
360
736
  function pruneOldRuns(db, days, logger) {
@@ -421,6 +797,8 @@ function createMaintenance(db, config, logger) {
421
797
 
422
798
  /**
423
799
  * Shared HTTP utility for making POST requests.
800
+ *
801
+ * @module
424
802
  */
425
803
  /** Make an HTTP/HTTPS POST request. Returns the parsed JSON response body. */
426
804
  function httpPost(url, headers, body, timeoutMs = 30000) {
@@ -468,6 +846,8 @@ function httpPost(url, headers, body, timeoutMs = 30000) {
468
846
 
469
847
  /**
470
848
  * OpenClaw Gateway HTTP client for spawning and monitoring sessions.
849
+ *
850
+ * @module
471
851
  */
472
852
  /** Make an HTTP POST request to the Gateway /tools/invoke endpoint. */
473
853
  function invokeGateway(url, token, tool, args, timeoutMs = 30000) {
@@ -534,6 +914,8 @@ function createGatewayClient(options) {
534
914
 
535
915
  /**
536
916
  * Slack notification module. Sends job completion/failure messages via Slack Web API (chat.postMessage). Falls back gracefully if no token.
917
+ *
918
+ * @module
537
919
  */
538
920
  /** Post a message to Slack via chat.postMessage API. */
539
921
  async function postToSlack(token, channel, text) {
@@ -551,21 +933,21 @@ function createNotifier(config) {
551
933
  return {
552
934
  async notifySuccess(jobName, durationMs, channel) {
553
935
  if (!slackToken) {
554
- console.warn(`No Slack token configured skipping success notification for ${jobName}`);
936
+ console.warn(`No Slack token configured \u2014 skipping success notification for ${jobName}`);
555
937
  return;
556
938
  }
557
939
  const durationSec = (durationMs / 1000).toFixed(1);
558
- const text = `?? *${jobName}* completed (${durationSec}s)`;
940
+ const text = `\u2705 *${jobName}* completed (${durationSec}s)`;
559
941
  await postToSlack(slackToken, channel, text);
560
942
  },
561
943
  async notifyFailure(jobName, durationMs, error, channel) {
562
944
  if (!slackToken) {
563
- console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
945
+ console.warn(`No Slack token configured \u2014 skipping failure notification for ${jobName}`);
564
946
  return;
565
947
  }
566
948
  const durationSec = (durationMs / 1000).toFixed(1);
567
949
  const errorMsg = error ? `: ${error}` : '';
568
- const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
950
+ const text = `\u274c *${jobName}* failed (${durationSec}s)${errorMsg}`;
569
951
  await postToSlack(slackToken, channel, text);
570
952
  },
571
953
  async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
@@ -585,6 +967,8 @@ function createNotifier(config) {
585
967
 
586
968
  /**
587
969
  * Job executor. Spawns job scripts as child processes, captures output, parses result metadata, enforces timeouts.
970
+ *
971
+ * @module
588
972
  */
589
973
  /** Ring buffer for capturing last N lines of output. */
590
974
  class RingBuffer {
@@ -646,14 +1030,23 @@ function resolveCommand(script) {
646
1030
  * Execute a job script as a child process. Captures output, parses metadata, enforces timeout.
647
1031
  */
648
1032
  function executeJob(options) {
649
- const { script, dbPath, jobId, runId, timeoutMs, commandResolver } = options;
1033
+ const { script, dbPath, jobId, runId, timeoutMs, commandResolver, sourceType = 'path', } = options;
650
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
+ }
651
1044
  return new Promise((resolve) => {
652
1045
  const stdoutBuffer = new RingBuffer(100);
653
1046
  const stderrBuffer = new RingBuffer(100);
654
1047
  const { command, args } = commandResolver
655
- ? commandResolver(script)
656
- : resolveCommand(script);
1048
+ ? commandResolver(effectiveScript)
1049
+ : resolveCommand(effectiveScript);
657
1050
  const child = spawn(command, args, {
658
1051
  env: {
659
1052
  ...process.env,
@@ -689,6 +1082,7 @@ function executeJob(options) {
689
1082
  child.on('close', (exitCode) => {
690
1083
  if (timeoutHandle)
691
1084
  clearTimeout(timeoutHandle);
1085
+ cleanupTempFile(tempFile);
692
1086
  const durationMs = Date.now() - startTime;
693
1087
  const stdoutTail = stdoutBuffer.getAll();
694
1088
  const stderrTail = stderrBuffer.getAll();
@@ -733,6 +1127,7 @@ function executeJob(options) {
733
1127
  child.on('error', (err) => {
734
1128
  if (timeoutHandle)
735
1129
  clearTimeout(timeoutHandle);
1130
+ cleanupTempFile(tempFile);
736
1131
  const durationMs = Date.now() - startTime;
737
1132
  resolve({
738
1133
  status: 'error',
@@ -747,31 +1142,73 @@ function executeJob(options) {
747
1142
  });
748
1143
  });
749
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
+ }
750
1156
 
751
1157
  /**
752
- * 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
753
1161
  */
1162
+ /** Maximum setTimeout delay (Node.js limit: ~24.8 days). */
1163
+ const MAX_TIMEOUT_MS = 2_147_483_647;
754
1164
  function createCronRegistry(deps) {
755
1165
  const { db, logger, onScheduledRun } = deps;
756
- const crons = new Map();
757
- const cronSchedules = new Map();
1166
+ const handles = new Map();
1167
+ const scheduleStrings = new Map();
758
1168
  const failedRegistrations = new Set();
759
- 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) {
760
1209
  try {
761
- const jobId = job.id;
762
- const cron = new Cron(job.schedule, () => {
763
- // Re-read job from DB to get current configuration
764
- const currentJob = db
765
- .prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
766
- .get(jobId);
767
- if (!currentJob) {
768
- logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
769
- return;
770
- }
771
- onScheduledRun(currentJob);
772
- });
773
- crons.set(job.id, cron);
774
- cronSchedules.set(job.id, job.schedule);
1210
+ scheduleNext(job.id, job.schedule);
1211
+ scheduleStrings.set(job.id, job.schedule);
775
1212
  failedRegistrations.delete(job.id);
776
1213
  logger.info({ jobId: job.id, schedule: job.schedule }, 'Scheduled job');
777
1214
  return true;
@@ -788,39 +1225,39 @@ function createCronRegistry(deps) {
788
1225
  .all();
789
1226
  const enabledById = new Map(enabledJobs.map((j) => [j.id, j]));
790
1227
  // Remove disabled/deleted jobs
791
- for (const [jobId, cron] of crons.entries()) {
1228
+ for (const [jobId, handle] of handles.entries()) {
792
1229
  if (!enabledById.has(jobId)) {
793
- cron.stop();
794
- crons.delete(jobId);
795
- cronSchedules.delete(jobId);
1230
+ handle.cancel();
1231
+ handles.delete(jobId);
1232
+ scheduleStrings.delete(jobId);
796
1233
  }
797
1234
  }
798
1235
  const failedIds = [];
799
1236
  // Add or update enabled jobs
800
1237
  for (const job of enabledJobs) {
801
- const existingCron = crons.get(job.id);
802
- const existingSchedule = cronSchedules.get(job.id);
803
- if (!existingCron) {
804
- if (!registerCron(job))
1238
+ const existingHandle = handles.get(job.id);
1239
+ const existingSchedule = scheduleStrings.get(job.id);
1240
+ if (!existingHandle) {
1241
+ if (!registerSchedule(job))
805
1242
  failedIds.push(job.id);
806
1243
  continue;
807
1244
  }
808
1245
  if (existingSchedule !== job.schedule) {
809
- existingCron.stop();
810
- crons.delete(job.id);
811
- cronSchedules.delete(job.id);
812
- if (!registerCron(job))
1246
+ existingHandle.cancel();
1247
+ handles.delete(job.id);
1248
+ scheduleStrings.delete(job.id);
1249
+ if (!registerSchedule(job))
813
1250
  failedIds.push(job.id);
814
1251
  }
815
1252
  }
816
1253
  return { totalEnabled: enabledJobs.length, failedIds };
817
1254
  }
818
1255
  function stopAll() {
819
- for (const cron of crons.values()) {
820
- cron.stop();
1256
+ for (const handle of handles.values()) {
1257
+ handle.cancel();
821
1258
  }
822
- crons.clear();
823
- cronSchedules.clear();
1259
+ handles.clear();
1260
+ scheduleStrings.clear();
824
1261
  }
825
1262
  return {
826
1263
  reconcile,
@@ -833,6 +1270,8 @@ function createCronRegistry(deps) {
833
1270
 
834
1271
  /**
835
1272
  * Run record repository for managing job execution records.
1273
+ *
1274
+ * @module
836
1275
  */
837
1276
  /** Create a run repository for the given database connection. */
838
1277
  function createRunRepository(db) {
@@ -854,6 +1293,8 @@ function createRunRepository(db) {
854
1293
 
855
1294
  /**
856
1295
  * Session executor for job type='session'. Spawns OpenClaw Gateway sessions and polls for completion.
1296
+ *
1297
+ * @module
857
1298
  */
858
1299
  /** File extensions that indicate a script rather than a prompt. */
859
1300
  const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ps1', '.cmd', '.bat'];
@@ -953,7 +1394,9 @@ async function executeSession(options) {
953
1394
  }
954
1395
 
955
1396
  /**
956
- * 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
957
1400
  */
958
1401
  // JobRow is imported from cron-registry
959
1402
  /**
@@ -973,7 +1416,7 @@ function createScheduler(deps) {
973
1416
  let reconcileInterval = null;
974
1417
  /** Execute a job: create run record, run script, update record, send notifications. */
975
1418
  async function runJob(job, trigger) {
976
- 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;
977
1420
  // Check concurrency limit
978
1421
  if (runningJobs.size >= config.maxConcurrency) {
979
1422
  logger.warn({ jobId: id }, 'Max concurrency reached, skipping job');
@@ -1004,6 +1447,7 @@ function createScheduler(deps) {
1004
1447
  jobId: id,
1005
1448
  runId,
1006
1449
  timeoutMs: timeout_ms ?? undefined,
1450
+ sourceType: source_type ?? 'path',
1007
1451
  });
1008
1452
  }
1009
1453
  runRepository.finishRun(runId, result);
@@ -1093,6 +1537,8 @@ function createScheduler(deps) {
1093
1537
 
1094
1538
  /**
1095
1539
  * Main runner orchestrator. Wires up database, scheduler, API server, and handles graceful shutdown on SIGTERM/SIGINT.
1540
+ *
1541
+ * @module
1096
1542
  */
1097
1543
  /**
1098
1544
  * Create the runner. Initializes database, scheduler, API server, and sets up graceful shutdown.
@@ -1163,7 +1609,7 @@ function createRunner(config, deps) {
1163
1609
  getConfig: () => config,
1164
1610
  loggerConfig: { level: config.log.level, file: config.log.file },
1165
1611
  });
1166
- await server.listen({ port: config.port, host: '127.0.0.1' });
1612
+ await server.listen({ port: config.port, host: config.host });
1167
1613
  logger.info({ port: config.port }, 'API server listening');
1168
1614
  // Graceful shutdown
1169
1615
  const shutdown = async (signal) => {
@@ -1234,6 +1680,8 @@ const gatewaySchema = z.object({
1234
1680
  const runnerConfigSchema = z.object({
1235
1681
  /** HTTP server port for the runner API. */
1236
1682
  port: z.number().default(1937),
1683
+ /** Bind address for the HTTP server. */
1684
+ host: z.string().default('127.0.0.1'),
1237
1685
  /** Path to SQLite database file. */
1238
1686
  dbPath: z.string().default('./data/runner.sqlite'),
1239
1687
  /** Maximum number of concurrent job executions. */
@@ -1265,6 +1713,7 @@ const runnerConfigSchema = z.object({
1265
1713
  /** Minimal starter config template. */
1266
1714
  const INIT_CONFIG_TEMPLATE = {
1267
1715
  port: 1937,
1716
+ host: '127.0.0.1',
1268
1717
  dbPath: './data/runner.sqlite',
1269
1718
  maxConcurrency: 4,
1270
1719
  runRetentionDays: 30,
@@ -1539,12 +1988,10 @@ program
1539
1988
  .option('-c, --config <path>', 'Path to config file')
1540
1989
  .action((options) => {
1541
1990
  const config = loadConfig(options.config);
1542
- // Validate schedule expression before inserting
1543
- try {
1544
- new CronPattern(options.schedule);
1545
- }
1546
- catch (err) {
1547
- 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}`);
1548
1995
  process.exit(1);
1549
1996
  }
1550
1997
  // Validate overlap_policy