@karmaniverous/jeeves-runner 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,25 @@
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';
6
+ import { fileURLToPath } from 'node:url';
7
7
  import { pino } from 'pino';
8
8
  import Fastify from 'fastify';
9
9
  import { createConfigQueryHandler } from '@karmaniverous/jeeves';
10
+ import { z } from 'zod';
11
+ import { RRStack } from '@karmaniverous/rrstack';
12
+ import { Cron } from 'croner';
13
+ import { JSONPath } from 'jsonpath-plus';
10
14
  import { request as request$1 } from 'node:http';
11
15
  import { request } from 'node:https';
12
16
  import { spawn } from 'node:child_process';
13
- import { z } from 'zod';
17
+ import { tmpdir } from 'node:os';
14
18
 
15
19
  /**
16
20
  * SQLite connection manager. Creates DB file with parent directories, enables WAL mode for concurrency.
21
+ *
22
+ * @module
17
23
  */
18
24
  /**
19
25
  * Create and configure a SQLite database connection.
@@ -42,6 +48,8 @@ function closeConnection(db) {
42
48
 
43
49
  /**
44
50
  * Schema migration runner. Tracks applied migrations via schema_version table, applies pending migrations idempotently.
51
+ *
52
+ * @module
45
53
  */
46
54
  /** Initial schema migration SQL (embedded to avoid runtime file resolution issues). */
47
55
  const MIGRATION_001 = `
@@ -175,11 +183,16 @@ CREATE TABLE state_items (
175
183
  );
176
184
  CREATE INDEX idx_state_items_ns_key ON state_items(namespace, key);
177
185
  `;
186
+ /** Migration 004: Add source_type column to jobs. */
187
+ const MIGRATION_004 = `
188
+ ALTER TABLE jobs ADD COLUMN source_type TEXT DEFAULT 'path';
189
+ `;
178
190
  /** Registry of all migrations keyed by version number. */
179
191
  const MIGRATIONS = {
180
192
  1: MIGRATION_001,
181
193
  2: MIGRATION_002,
182
194
  3: MIGRATION_003,
195
+ 4: MIGRATION_004,
183
196
  };
184
197
  /**
185
198
  * Run all pending migrations. Creates schema_version table if needed, applies migrations in order.
@@ -208,6 +221,385 @@ function runMigrations(db) {
208
221
  }
209
222
  }
210
223
 
224
+ /**
225
+ * Schedule parsing, validation, and next-fire-time computation for cron and RRStack formats.
226
+ *
227
+ * @module
228
+ */
229
+ /**
230
+ * Attempt to parse a schedule string as RRStack JSON (non-null, non-array
231
+ * object). Returns the parsed options on success, or null if the string
232
+ * is not a JSON object.
233
+ */
234
+ function tryParseRRStack(schedule) {
235
+ try {
236
+ const parsed = JSON.parse(schedule);
237
+ if (typeof parsed === 'object' &&
238
+ parsed !== null &&
239
+ !Array.isArray(parsed)) {
240
+ return parsed;
241
+ }
242
+ return null;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ }
248
+ /**
249
+ * Compute the next fire time for a schedule string.
250
+ * Supports cron expressions (via croner) and RRStack JSON (via nextEvent).
251
+ *
252
+ * @param schedule - Cron expression or RRStack JSON string.
253
+ * @returns Next fire time as a Date, or null if none can be determined.
254
+ */
255
+ function getNextFireTime(schedule) {
256
+ const rrOpts = tryParseRRStack(schedule);
257
+ if (rrOpts) {
258
+ const stack = new RRStack(rrOpts);
259
+ const next = stack.nextEvent();
260
+ if (!next)
261
+ return null;
262
+ const unit = stack.timeUnit;
263
+ return new Date(unit === 's' ? next.at * 1000 : next.at);
264
+ }
265
+ const cron = new Cron(schedule);
266
+ return cron.nextRun() ?? null;
267
+ }
268
+ /**
269
+ * Validate a schedule string as either a cron expression or RRStack JSON.
270
+ *
271
+ * @param schedule - Cron expression or RRStack JSON string.
272
+ * @returns Validation result with format on success, or error message on failure.
273
+ */
274
+ function validateSchedule(schedule) {
275
+ const rrOpts = tryParseRRStack(schedule);
276
+ if (rrOpts) {
277
+ try {
278
+ new RRStack(rrOpts);
279
+ return { valid: true, format: 'rrstack' };
280
+ }
281
+ catch (err) {
282
+ return {
283
+ valid: false,
284
+ error: `Invalid RRStack schedule: ${err instanceof Error ? err.message : String(err)}`,
285
+ };
286
+ }
287
+ }
288
+ try {
289
+ new Cron(schedule);
290
+ return { valid: true, format: 'cron' };
291
+ }
292
+ catch (err) {
293
+ return {
294
+ valid: false,
295
+ error: `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`,
296
+ };
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Job CRUD routes: create, update, delete, enable/disable, and script update endpoints.
302
+ *
303
+ * @module
304
+ */
305
+ /** Zod schema for job creation/update request body. */
306
+ const createJobSchema = z.object({
307
+ id: z.string().min(1),
308
+ name: z.string().min(1),
309
+ schedule: z.string().min(1),
310
+ script: z.string().min(1),
311
+ source_type: z.enum(['path', 'inline']).default('path'),
312
+ type: z.enum(['script', 'session']).default('script'),
313
+ timeout_seconds: z.number().positive().optional(),
314
+ overlap_policy: z.enum(['skip', 'allow']).default('skip'),
315
+ enabled: z.boolean().default(true),
316
+ description: z.string().optional(),
317
+ on_failure: z.string().nullable().optional(),
318
+ on_success: z.string().nullable().optional(),
319
+ });
320
+ /** Zod schema for job update (all fields optional except what's set). */
321
+ const updateJobSchema = createJobSchema.omit({ id: true }).partial();
322
+ /** Zod schema for script update request body. */
323
+ const updateScriptSchema = z.object({
324
+ script: z.string().min(1),
325
+ source_type: z.enum(['path', 'inline']).optional(),
326
+ });
327
+ /** Standard error message for missing job resources. */
328
+ const JOB_NOT_FOUND = 'Job not found';
329
+ /**
330
+ * Register job CRUD routes on the Fastify instance.
331
+ */
332
+ function registerJobRoutes(app, deps) {
333
+ const { db, scheduler } = deps;
334
+ /** POST /jobs — Create a new job. */
335
+ app.post('/jobs', (request, reply) => {
336
+ const parsed = createJobSchema.safeParse(request.body);
337
+ if (!parsed.success) {
338
+ reply.code(400);
339
+ return { error: parsed.error.message };
340
+ }
341
+ const data = parsed.data;
342
+ const validation = validateSchedule(data.schedule);
343
+ if (!validation.valid) {
344
+ reply.code(400);
345
+ return { error: validation.error };
346
+ }
347
+ const timeoutMs = data.timeout_seconds ? data.timeout_seconds * 1000 : null;
348
+ db.prepare(`INSERT INTO jobs (id, name, schedule, script, source_type, type, timeout_ms,
349
+ overlap_policy, enabled, description, on_failure, on_success)
350
+ 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);
351
+ scheduler.reconcileNow();
352
+ reply.code(201);
353
+ return { ok: true, id: data.id };
354
+ });
355
+ /** PATCH /jobs/:id — Partial update of an existing job. */
356
+ app.patch('/jobs/:id', (request, reply) => {
357
+ const parsed = updateJobSchema.safeParse(request.body);
358
+ if (!parsed.success) {
359
+ reply.code(400);
360
+ return { error: parsed.error.message };
361
+ }
362
+ const existing = db
363
+ .prepare('SELECT id FROM jobs WHERE id = ?')
364
+ .get(request.params.id);
365
+ if (!existing) {
366
+ reply.code(404);
367
+ return { error: JOB_NOT_FOUND };
368
+ }
369
+ const data = parsed.data;
370
+ if (data.schedule) {
371
+ const validation = validateSchedule(data.schedule);
372
+ if (!validation.valid) {
373
+ reply.code(400);
374
+ return { error: validation.error };
375
+ }
376
+ }
377
+ /** Map input field → DB column + value transform. */
378
+ const fieldMap = [
379
+ { input: 'name', column: 'name' },
380
+ { input: 'schedule', column: 'schedule' },
381
+ { input: 'script', column: 'script' },
382
+ { input: 'source_type', column: 'source_type' },
383
+ { input: 'type', column: 'type' },
384
+ {
385
+ input: 'timeout_seconds',
386
+ column: 'timeout_ms',
387
+ transform: (v) => v * 1000,
388
+ },
389
+ { input: 'overlap_policy', column: 'overlap_policy' },
390
+ {
391
+ input: 'enabled',
392
+ column: 'enabled',
393
+ transform: (v) => (v ? 1 : 0),
394
+ },
395
+ { input: 'description', column: 'description' },
396
+ { input: 'on_failure', column: 'on_failure' },
397
+ { input: 'on_success', column: 'on_success' },
398
+ ];
399
+ const sets = [];
400
+ const values = [];
401
+ for (const { input, column, transform } of fieldMap) {
402
+ if (data[input] !== undefined) {
403
+ sets.push(`${column} = ?`);
404
+ values.push(transform ? transform(data[input]) : data[input]);
405
+ }
406
+ }
407
+ if (sets.length > 0) {
408
+ sets.push("updated_at = datetime('now')");
409
+ values.push(request.params.id);
410
+ db.prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values);
411
+ }
412
+ scheduler.reconcileNow();
413
+ return { ok: true };
414
+ });
415
+ /** DELETE /jobs/:id — Delete a job and its runs. */
416
+ app.delete('/jobs/:id', (request, reply) => {
417
+ db.prepare('DELETE FROM runs WHERE job_id = ?').run(request.params.id);
418
+ const result = db
419
+ .prepare('DELETE FROM jobs WHERE id = ?')
420
+ .run(request.params.id);
421
+ if (result.changes === 0) {
422
+ reply.code(404);
423
+ return { error: JOB_NOT_FOUND };
424
+ }
425
+ scheduler.reconcileNow();
426
+ return { ok: true };
427
+ });
428
+ /** Register a PATCH toggle endpoint (enable or disable). */
429
+ function registerToggle(path, enabledValue) {
430
+ app.patch(path, (request, reply) => {
431
+ const result = db
432
+ .prepare('UPDATE jobs SET enabled = ? WHERE id = ?')
433
+ .run(enabledValue, request.params.id);
434
+ if (result.changes === 0) {
435
+ reply.code(404);
436
+ return { error: JOB_NOT_FOUND };
437
+ }
438
+ scheduler.reconcileNow();
439
+ return { ok: true };
440
+ });
441
+ }
442
+ registerToggle('/jobs/:id/enable', 1);
443
+ registerToggle('/jobs/:id/disable', 0);
444
+ /** PUT /jobs/:id/script — Update job script and source_type. */
445
+ app.put('/jobs/:id/script', (request, reply) => {
446
+ const parsed = updateScriptSchema.safeParse(request.body);
447
+ if (!parsed.success) {
448
+ reply.code(400);
449
+ return { error: parsed.error.message };
450
+ }
451
+ const data = parsed.data;
452
+ const result = data.source_type
453
+ ? db
454
+ .prepare('UPDATE jobs SET script = ?, source_type = ? WHERE id = ?')
455
+ .run(data.script, data.source_type, request.params.id)
456
+ : db
457
+ .prepare('UPDATE jobs SET script = ? WHERE id = ?')
458
+ .run(data.script, request.params.id);
459
+ if (result.changes === 0) {
460
+ reply.code(404);
461
+ return { error: JOB_NOT_FOUND };
462
+ }
463
+ scheduler.reconcileNow();
464
+ return { ok: true };
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Queue inspection routes: list queues, queue status, and peek at pending items.
470
+ *
471
+ * @module
472
+ */
473
+ /**
474
+ * Register queue inspection routes on the Fastify instance.
475
+ */
476
+ function registerQueueRoutes(app, deps) {
477
+ const { db } = deps;
478
+ /** GET /queues — List all distinct queues that have items. */
479
+ app.get('/queues', () => {
480
+ const rows = db
481
+ .prepare('SELECT DISTINCT queue_id FROM queue_items')
482
+ .all();
483
+ return { queues: rows.map((r) => r.queue_id) };
484
+ });
485
+ /** GET /queues/:name/status — Queue depth, claimed count, failed count, oldest age. */
486
+ app.get('/queues/:name/status', (request) => {
487
+ const row = db
488
+ .prepare(`SELECT
489
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS depth,
490
+ SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) AS claimed,
491
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
492
+ MIN(CASE WHEN status = 'pending' THEN created_at END) AS oldest
493
+ FROM queue_items
494
+ WHERE queue_id = ?`)
495
+ .get(request.params.name);
496
+ return {
497
+ depth: row.depth ?? 0,
498
+ claimedCount: row.claimed ?? 0,
499
+ failedCount: row.failed ?? 0,
500
+ oldestAge: row.oldest
501
+ ? Date.now() - new Date(row.oldest).getTime()
502
+ : null,
503
+ };
504
+ });
505
+ /** GET /queues/:name/peek — Non-claiming read of pending items. */
506
+ app.get('/queues/:name/peek', (request) => {
507
+ const name = request.params.name;
508
+ const limit = parseInt(request.query.limit ?? '10', 10);
509
+ const items = db
510
+ .prepare(`SELECT id, payload, priority, created_at FROM queue_items
511
+ WHERE queue_id = ? AND status = 'pending'
512
+ ORDER BY priority DESC, created_at
513
+ LIMIT ?`)
514
+ .all(name, limit);
515
+ return {
516
+ items: items.map((item) => ({
517
+ id: item.id,
518
+ payload: JSON.parse(item.payload),
519
+ priority: item.priority,
520
+ createdAt: item.created_at,
521
+ })),
522
+ };
523
+ });
524
+ }
525
+
526
+ /**
527
+ * State inspection routes: list namespaces, read scalar state, and read collection items.
528
+ *
529
+ * @module
530
+ */
531
+ /**
532
+ * Register state inspection routes on the Fastify instance.
533
+ */
534
+ function registerStateRoutes(app, deps) {
535
+ const { db } = deps;
536
+ /** GET /state — List all distinct namespaces. */
537
+ app.get('/state', () => {
538
+ const rows = db
539
+ .prepare('SELECT DISTINCT namespace FROM state')
540
+ .all();
541
+ return { namespaces: rows.map((r) => r.namespace) };
542
+ });
543
+ /** GET /state/:namespace — Materialise all scalar state as key-value map. */
544
+ app.get('/state/:namespace', (request) => {
545
+ const { namespace } = request.params;
546
+ const rows = db
547
+ .prepare(`SELECT key, value FROM state
548
+ WHERE namespace = ?
549
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`)
550
+ .all(namespace);
551
+ const state = {};
552
+ for (const row of rows) {
553
+ state[row.key] = row.value;
554
+ }
555
+ if (request.query.path) {
556
+ const result = JSONPath({
557
+ path: request.query.path,
558
+ json: state,
559
+ });
560
+ return { result, count: Array.isArray(result) ? result.length : 1 };
561
+ }
562
+ return state;
563
+ });
564
+ /** GET /state/:namespace/:key — Read collection items for a state key. */
565
+ app.get('/state/:namespace/:key', (request) => {
566
+ const { namespace, key } = request.params;
567
+ const limit = parseInt(request.query.limit ?? '100', 10);
568
+ const order = request.query.order === 'asc' ? 'ASC' : 'DESC';
569
+ // First check scalar state value
570
+ const scalar = db
571
+ .prepare(`SELECT value FROM state
572
+ WHERE namespace = ? AND key = ?
573
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`)
574
+ .get(namespace, key);
575
+ // Get collection items
576
+ const items = db
577
+ .prepare(`SELECT item_key, value, updated_at FROM state_items
578
+ WHERE namespace = ? AND key = ?
579
+ ORDER BY updated_at ${order}
580
+ LIMIT ?`)
581
+ .all(namespace, key, limit);
582
+ const mappedItems = items.map((item) => ({
583
+ itemKey: item.item_key,
584
+ value: item.value,
585
+ updatedAt: item.updated_at,
586
+ }));
587
+ const body = {
588
+ value: scalar?.value ?? null,
589
+ items: mappedItems,
590
+ count: mappedItems.length,
591
+ };
592
+ if (request.query.path) {
593
+ const result = JSONPath({
594
+ path: request.query.path,
595
+ json: body,
596
+ });
597
+ return { result, count: Array.isArray(result) ? result.length : 1 };
598
+ }
599
+ return body;
600
+ });
601
+ }
602
+
211
603
  /**
212
604
  * Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, system stats, and config queries.
213
605
  *
@@ -217,13 +609,31 @@ function runMigrations(db) {
217
609
  * Register all API routes on the Fastify instance.
218
610
  */
219
611
  function registerRoutes(app, deps) {
220
- const { db, scheduler, getConfig } = deps;
221
- /** GET /healthHealth check. */
222
- app.get('/health', () => {
612
+ const { db, scheduler, getConfig, version } = deps;
613
+ /** GET /statusUnified status endpoint (single health/metadata entrypoint). */
614
+ app.get('/status', () => {
615
+ const totalJobs = db
616
+ .prepare('SELECT COUNT(*) as count FROM jobs')
617
+ .get();
618
+ const runningCount = scheduler.getRunningJobs().length;
619
+ const failedCount = scheduler.getFailedRegistrations().length;
620
+ const okLastHour = db
621
+ .prepare(`SELECT COUNT(*) as count FROM runs
622
+ WHERE status = 'ok' AND started_at > datetime('now', '-1 hour')`)
623
+ .get();
624
+ const errorsLastHour = db
625
+ .prepare(`SELECT COUNT(*) as count FROM runs
626
+ WHERE status IN ('error', 'timeout') AND started_at > datetime('now', '-1 hour')`)
627
+ .get();
223
628
  return {
224
- ok: true,
629
+ status: 'ok',
630
+ version,
225
631
  uptime: process.uptime(),
226
- failedRegistrations: scheduler.getFailedRegistrations().length,
632
+ totalJobs: totalJobs.count,
633
+ running: runningCount,
634
+ failedRegistrations: failedCount,
635
+ okLastHour: okLastHour.count,
636
+ errorsLastHour: errorsLastHour.count,
227
637
  };
228
638
  });
229
639
  /** GET /jobs — List all jobs with last run status. */
@@ -237,7 +647,7 @@ function registerRoutes(app, deps) {
237
647
  return { jobs: rows };
238
648
  });
239
649
  /** GET /jobs/:id — Single job detail. */
240
- app.get('/jobs/:id', async (request, reply) => {
650
+ app.get('/jobs/:id', (request, reply) => {
241
651
  const job = db
242
652
  .prepare('SELECT * FROM jobs WHERE id = ?')
243
653
  .get(request.params.id);
@@ -266,53 +676,6 @@ function registerRoutes(app, deps) {
266
676
  return { error: err instanceof Error ? err.message : 'Unknown error' };
267
677
  }
268
678
  });
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
- /** GET /stats — Aggregate job statistics. */
294
- app.get('/stats', () => {
295
- const totalJobs = db
296
- .prepare('SELECT COUNT(*) as count FROM jobs')
297
- .get();
298
- const runningCount = scheduler.getRunningJobs().length;
299
- const failedCount = scheduler.getFailedRegistrations().length;
300
- const okLastHour = db
301
- .prepare(`SELECT COUNT(*) as count FROM runs
302
- WHERE status = 'ok' AND started_at > datetime('now', '-1 hour')`)
303
- .get();
304
- const errorsLastHour = db
305
- .prepare(`SELECT COUNT(*) as count FROM runs
306
- WHERE status IN ('error', 'timeout') AND started_at > datetime('now', '-1 hour')`)
307
- .get();
308
- return {
309
- totalJobs: totalJobs.count,
310
- running: runningCount,
311
- failedRegistrations: failedCount,
312
- okLastHour: okLastHour.count,
313
- errorsLastHour: errorsLastHour.count,
314
- };
315
- });
316
679
  /** GET /config — Query effective configuration via JSONPath. */
317
680
  const configHandler = createConfigQueryHandler(getConfig);
318
681
  app.get('/config', async (request, reply) => {
@@ -321,10 +684,17 @@ function registerRoutes(app, deps) {
321
684
  });
322
685
  return reply.status(result.status).send(result.body);
323
686
  });
687
+ // Register job CRUD routes (POST, PATCH, DELETE, PATCH enable/disable, PUT script)
688
+ registerJobRoutes(app, { db, scheduler });
689
+ // Register queue and state inspection routes
690
+ registerQueueRoutes(app, { db });
691
+ registerStateRoutes(app, { db });
324
692
  }
325
693
 
326
694
  /**
327
695
  * Fastify HTTP server for runner API. Creates server instance with logging, registers routes, listens on configured port (localhost only).
696
+ *
697
+ * @module
328
698
  */
329
699
  /**
330
700
  * Create and configure the Fastify server. Routes are registered but server is not started.
@@ -349,12 +719,15 @@ function createServer(deps) {
349
719
  db: deps.db,
350
720
  scheduler: deps.scheduler,
351
721
  getConfig: deps.getConfig,
722
+ version: deps.version,
352
723
  });
353
724
  return app;
354
725
  }
355
726
 
356
727
  /**
357
728
  * Database maintenance tasks: run retention pruning and expired state cleanup.
729
+ *
730
+ * @module
358
731
  */
359
732
  /** Delete runs older than the configured retention period. */
360
733
  function pruneOldRuns(db, days, logger) {
@@ -421,6 +794,8 @@ function createMaintenance(db, config, logger) {
421
794
 
422
795
  /**
423
796
  * Shared HTTP utility for making POST requests.
797
+ *
798
+ * @module
424
799
  */
425
800
  /** Make an HTTP/HTTPS POST request. Returns the parsed JSON response body. */
426
801
  function httpPost(url, headers, body, timeoutMs = 30000) {
@@ -468,6 +843,8 @@ function httpPost(url, headers, body, timeoutMs = 30000) {
468
843
 
469
844
  /**
470
845
  * OpenClaw Gateway HTTP client for spawning and monitoring sessions.
846
+ *
847
+ * @module
471
848
  */
472
849
  /** Make an HTTP POST request to the Gateway /tools/invoke endpoint. */
473
850
  function invokeGateway(url, token, tool, args, timeoutMs = 30000) {
@@ -534,6 +911,8 @@ function createGatewayClient(options) {
534
911
 
535
912
  /**
536
913
  * Slack notification module. Sends job completion/failure messages via Slack Web API (chat.postMessage). Falls back gracefully if no token.
914
+ *
915
+ * @module
537
916
  */
538
917
  /** Post a message to Slack via chat.postMessage API. */
539
918
  async function postToSlack(token, channel, text) {
@@ -551,21 +930,21 @@ function createNotifier(config) {
551
930
  return {
552
931
  async notifySuccess(jobName, durationMs, channel) {
553
932
  if (!slackToken) {
554
- console.warn(`No Slack token configured skipping success notification for ${jobName}`);
933
+ console.warn(`No Slack token configured \u2014 skipping success notification for ${jobName}`);
555
934
  return;
556
935
  }
557
936
  const durationSec = (durationMs / 1000).toFixed(1);
558
- const text = `?? *${jobName}* completed (${durationSec}s)`;
937
+ const text = `\u2705 *${jobName}* completed (${durationSec}s)`;
559
938
  await postToSlack(slackToken, channel, text);
560
939
  },
561
940
  async notifyFailure(jobName, durationMs, error, channel) {
562
941
  if (!slackToken) {
563
- console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
942
+ console.warn(`No Slack token configured \u2014 skipping failure notification for ${jobName}`);
564
943
  return;
565
944
  }
566
945
  const durationSec = (durationMs / 1000).toFixed(1);
567
946
  const errorMsg = error ? `: ${error}` : '';
568
- const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
947
+ const text = `\u274c *${jobName}* failed (${durationSec}s)${errorMsg}`;
569
948
  await postToSlack(slackToken, channel, text);
570
949
  },
571
950
  async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
@@ -585,6 +964,8 @@ function createNotifier(config) {
585
964
 
586
965
  /**
587
966
  * Job executor. Spawns job scripts as child processes, captures output, parses result metadata, enforces timeouts.
967
+ *
968
+ * @module
588
969
  */
589
970
  /** Ring buffer for capturing last N lines of output. */
590
971
  class RingBuffer {
@@ -646,14 +1027,23 @@ function resolveCommand(script) {
646
1027
  * Execute a job script as a child process. Captures output, parses metadata, enforces timeout.
647
1028
  */
648
1029
  function executeJob(options) {
649
- const { script, dbPath, jobId, runId, timeoutMs, commandResolver } = options;
1030
+ const { script, dbPath, jobId, runId, timeoutMs, commandResolver, sourceType = 'path', } = options;
650
1031
  const startTime = Date.now();
1032
+ // For inline scripts, write to a temp file and clean up after.
1033
+ let tempFile = null;
1034
+ let effectiveScript = script;
1035
+ if (sourceType === 'inline') {
1036
+ const tempDir = mkdtempSync(join(tmpdir(), 'jr-inline-'));
1037
+ tempFile = join(tempDir, 'inline.js');
1038
+ writeFileSync(tempFile, script);
1039
+ effectiveScript = tempFile;
1040
+ }
651
1041
  return new Promise((resolve) => {
652
1042
  const stdoutBuffer = new RingBuffer(100);
653
1043
  const stderrBuffer = new RingBuffer(100);
654
1044
  const { command, args } = commandResolver
655
- ? commandResolver(script)
656
- : resolveCommand(script);
1045
+ ? commandResolver(effectiveScript)
1046
+ : resolveCommand(effectiveScript);
657
1047
  const child = spawn(command, args, {
658
1048
  env: {
659
1049
  ...process.env,
@@ -689,6 +1079,7 @@ function executeJob(options) {
689
1079
  child.on('close', (exitCode) => {
690
1080
  if (timeoutHandle)
691
1081
  clearTimeout(timeoutHandle);
1082
+ cleanupTempFile(tempFile);
692
1083
  const durationMs = Date.now() - startTime;
693
1084
  const stdoutTail = stdoutBuffer.getAll();
694
1085
  const stderrTail = stderrBuffer.getAll();
@@ -733,6 +1124,7 @@ function executeJob(options) {
733
1124
  child.on('error', (err) => {
734
1125
  if (timeoutHandle)
735
1126
  clearTimeout(timeoutHandle);
1127
+ cleanupTempFile(tempFile);
736
1128
  const durationMs = Date.now() - startTime;
737
1129
  resolve({
738
1130
  status: 'error',
@@ -747,31 +1139,73 @@ function executeJob(options) {
747
1139
  });
748
1140
  });
749
1141
  }
1142
+ /** Remove a temp file created for inline script execution. */
1143
+ function cleanupTempFile(tempFile) {
1144
+ if (!tempFile)
1145
+ return;
1146
+ try {
1147
+ unlinkSync(tempFile);
1148
+ }
1149
+ catch {
1150
+ // Ignore cleanup errors
1151
+ }
1152
+ }
750
1153
 
751
1154
  /**
752
- * Cron registration and reconciliation utilities.
1155
+ * Schedule registration and reconciliation. Supports cron (croner) and RRStack schedule formats via unified setTimeout-based scheduling.
1156
+ *
1157
+ * @module
753
1158
  */
1159
+ /** Maximum setTimeout delay (Node.js limit: ~24.8 days). */
1160
+ const MAX_TIMEOUT_MS = 2_147_483_647;
754
1161
  function createCronRegistry(deps) {
755
1162
  const { db, logger, onScheduledRun } = deps;
756
- const crons = new Map();
757
- const cronSchedules = new Map();
1163
+ const handles = new Map();
1164
+ const scheduleStrings = new Map();
758
1165
  const failedRegistrations = new Set();
759
- function registerCron(job) {
1166
+ /**
1167
+ * Schedule the next fire for a job. Computes next fire time, sets a
1168
+ * setTimeout, and re-arms after each fire.
1169
+ */
1170
+ function scheduleNext(jobId, schedule) {
1171
+ const nextDate = getNextFireTime(schedule);
1172
+ if (!nextDate) {
1173
+ logger.warn({ jobId }, 'No upcoming fire time, job will not fire');
1174
+ return;
1175
+ }
1176
+ const delayMs = Math.max(0, nextDate.getTime() - Date.now());
1177
+ // Node.js setTimeout max is ~24.8 days. If delay exceeds that,
1178
+ // set an intermediate wakeup and re-check.
1179
+ const effectiveDelay = Math.min(delayMs, MAX_TIMEOUT_MS);
1180
+ const isIntermediate = delayMs > MAX_TIMEOUT_MS;
1181
+ const timeout = setTimeout(() => {
1182
+ if (isIntermediate) {
1183
+ // Woke up early just to re-check; schedule again.
1184
+ scheduleNext(jobId, schedule);
1185
+ return;
1186
+ }
1187
+ // Re-read job from DB to get current configuration
1188
+ const currentJob = db
1189
+ .prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
1190
+ .get(jobId);
1191
+ if (!currentJob) {
1192
+ logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
1193
+ return;
1194
+ }
1195
+ onScheduledRun(currentJob);
1196
+ // Re-arm for the next occurrence
1197
+ scheduleNext(jobId, schedule);
1198
+ }, effectiveDelay);
1199
+ handles.set(jobId, {
1200
+ cancel: () => {
1201
+ clearTimeout(timeout);
1202
+ },
1203
+ });
1204
+ }
1205
+ function registerSchedule(job) {
760
1206
  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);
1207
+ scheduleNext(job.id, job.schedule);
1208
+ scheduleStrings.set(job.id, job.schedule);
775
1209
  failedRegistrations.delete(job.id);
776
1210
  logger.info({ jobId: job.id, schedule: job.schedule }, 'Scheduled job');
777
1211
  return true;
@@ -788,39 +1222,39 @@ function createCronRegistry(deps) {
788
1222
  .all();
789
1223
  const enabledById = new Map(enabledJobs.map((j) => [j.id, j]));
790
1224
  // Remove disabled/deleted jobs
791
- for (const [jobId, cron] of crons.entries()) {
1225
+ for (const [jobId, handle] of handles.entries()) {
792
1226
  if (!enabledById.has(jobId)) {
793
- cron.stop();
794
- crons.delete(jobId);
795
- cronSchedules.delete(jobId);
1227
+ handle.cancel();
1228
+ handles.delete(jobId);
1229
+ scheduleStrings.delete(jobId);
796
1230
  }
797
1231
  }
798
1232
  const failedIds = [];
799
1233
  // Add or update enabled jobs
800
1234
  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))
1235
+ const existingHandle = handles.get(job.id);
1236
+ const existingSchedule = scheduleStrings.get(job.id);
1237
+ if (!existingHandle) {
1238
+ if (!registerSchedule(job))
805
1239
  failedIds.push(job.id);
806
1240
  continue;
807
1241
  }
808
1242
  if (existingSchedule !== job.schedule) {
809
- existingCron.stop();
810
- crons.delete(job.id);
811
- cronSchedules.delete(job.id);
812
- if (!registerCron(job))
1243
+ existingHandle.cancel();
1244
+ handles.delete(job.id);
1245
+ scheduleStrings.delete(job.id);
1246
+ if (!registerSchedule(job))
813
1247
  failedIds.push(job.id);
814
1248
  }
815
1249
  }
816
1250
  return { totalEnabled: enabledJobs.length, failedIds };
817
1251
  }
818
1252
  function stopAll() {
819
- for (const cron of crons.values()) {
820
- cron.stop();
1253
+ for (const handle of handles.values()) {
1254
+ handle.cancel();
821
1255
  }
822
- crons.clear();
823
- cronSchedules.clear();
1256
+ handles.clear();
1257
+ scheduleStrings.clear();
824
1258
  }
825
1259
  return {
826
1260
  reconcile,
@@ -833,6 +1267,8 @@ function createCronRegistry(deps) {
833
1267
 
834
1268
  /**
835
1269
  * Run record repository for managing job execution records.
1270
+ *
1271
+ * @module
836
1272
  */
837
1273
  /** Create a run repository for the given database connection. */
838
1274
  function createRunRepository(db) {
@@ -854,6 +1290,8 @@ function createRunRepository(db) {
854
1290
 
855
1291
  /**
856
1292
  * Session executor for job type='session'. Spawns OpenClaw Gateway sessions and polls for completion.
1293
+ *
1294
+ * @module
857
1295
  */
858
1296
  /** File extensions that indicate a script rather than a prompt. */
859
1297
  const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ps1', '.cmd', '.bat'];
@@ -953,7 +1391,9 @@ async function executeSession(options) {
953
1391
  }
954
1392
 
955
1393
  /**
956
- * Croner-based job scheduler. Loads enabled jobs, creates cron instances, manages execution, respects overlap policies and concurrency limits.
1394
+ * Job scheduler. Loads enabled jobs, registers schedules (cron or rrstack), manages execution, respects overlap policies and concurrency limits.
1395
+ *
1396
+ * @module
957
1397
  */
958
1398
  // JobRow is imported from cron-registry
959
1399
  /**
@@ -973,7 +1413,7 @@ function createScheduler(deps) {
973
1413
  let reconcileInterval = null;
974
1414
  /** Execute a job: create run record, run script, update record, send notifications. */
975
1415
  async function runJob(job, trigger) {
976
- const { id, name, script, type, timeout_ms, on_success, on_failure } = job;
1416
+ const { id, name, script, type, timeout_ms, on_success, on_failure, source_type, } = job;
977
1417
  // Check concurrency limit
978
1418
  if (runningJobs.size >= config.maxConcurrency) {
979
1419
  logger.warn({ jobId: id }, 'Max concurrency reached, skipping job');
@@ -1004,6 +1444,7 @@ function createScheduler(deps) {
1004
1444
  jobId: id,
1005
1445
  runId,
1006
1446
  timeoutMs: timeout_ms ?? undefined,
1447
+ sourceType: source_type ?? 'path',
1007
1448
  });
1008
1449
  }
1009
1450
  runRepository.finishRun(runId, result);
@@ -1093,6 +1534,8 @@ function createScheduler(deps) {
1093
1534
 
1094
1535
  /**
1095
1536
  * Main runner orchestrator. Wires up database, scheduler, API server, and handles graceful shutdown on SIGTERM/SIGINT.
1537
+ *
1538
+ * @module
1096
1539
  */
1097
1540
  /**
1098
1541
  * Create the runner. Initializes database, scheduler, API server, and sets up graceful shutdown.
@@ -1156,14 +1599,26 @@ function createRunner(config, deps) {
1156
1599
  });
1157
1600
  scheduler.start();
1158
1601
  logger.info('Scheduler started');
1602
+ // Read package version
1603
+ const pkgVersion = (() => {
1604
+ try {
1605
+ const dir = dirname(fileURLToPath(import.meta.url));
1606
+ const pkg = JSON.parse(readFileSync(resolve(dir, '..', 'package.json'), 'utf8'));
1607
+ return pkg.version ?? 'unknown';
1608
+ }
1609
+ catch {
1610
+ return 'unknown';
1611
+ }
1612
+ })();
1159
1613
  // API server
1160
1614
  server = createServer({
1161
1615
  db,
1162
1616
  scheduler,
1163
1617
  getConfig: () => config,
1618
+ version: pkgVersion,
1164
1619
  loggerConfig: { level: config.log.level, file: config.log.file },
1165
1620
  });
1166
- await server.listen({ port: config.port, host: '127.0.0.1' });
1621
+ await server.listen({ port: config.port, host: config.host });
1167
1622
  logger.info({ port: config.port }, 'API server listening');
1168
1623
  // Graceful shutdown
1169
1624
  const shutdown = async (signal) => {
@@ -1234,6 +1689,8 @@ const gatewaySchema = z.object({
1234
1689
  const runnerConfigSchema = z.object({
1235
1690
  /** HTTP server port for the runner API. */
1236
1691
  port: z.number().default(1937),
1692
+ /** Bind address for the HTTP server. */
1693
+ host: z.string().default('127.0.0.1'),
1237
1694
  /** Path to SQLite database file. */
1238
1695
  dbPath: z.string().default('./data/runner.sqlite'),
1239
1696
  /** Maximum number of concurrent job executions. */
@@ -1265,6 +1722,7 @@ const runnerConfigSchema = z.object({
1265
1722
  /** Minimal starter config template. */
1266
1723
  const INIT_CONFIG_TEMPLATE = {
1267
1724
  port: 1937,
1725
+ host: '127.0.0.1',
1268
1726
  dbPath: './data/runner.sqlite',
1269
1727
  maxConcurrency: 4,
1270
1728
  runRetentionDays: 30,
@@ -1513,7 +1971,7 @@ program
1513
1971
  const config = loadConfig(options.config);
1514
1972
  void (async () => {
1515
1973
  try {
1516
- const resp = await fetch(`http://127.0.0.1:${String(config.port)}/stats`);
1974
+ const resp = await fetch(`http://127.0.0.1:${String(config.port)}/status`);
1517
1975
  const stats = (await resp.json());
1518
1976
  console.log(JSON.stringify(stats, null, 2));
1519
1977
  }
@@ -1539,12 +1997,10 @@ program
1539
1997
  .option('-c, --config <path>', 'Path to config file')
1540
1998
  .action((options) => {
1541
1999
  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)}`);
2000
+ // Validate schedule expression (cron or rrstack) before inserting
2001
+ const scheduleValidation = validateSchedule(options.schedule);
2002
+ if (!scheduleValidation.valid) {
2003
+ console.error(`Invalid schedule: ${scheduleValidation.error}`);
1548
2004
  process.exit(1);
1549
2005
  }
1550
2006
  // Validate overlap_policy