@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.
package/dist/mjs/index.js CHANGED
@@ -1,15 +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
5
  import { createConfigQueryHandler } from '@karmaniverous/jeeves';
6
- import { dirname, extname } from 'node:path';
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';
7
10
  import { DatabaseSync } from 'node:sqlite';
8
11
  import { request as request$1 } from 'node:http';
9
12
  import { request } from 'node:https';
10
13
  import { spawn } from 'node:child_process';
11
- import { Cron } from 'croner';
12
- import { JSONPath } from 'jsonpath-plus';
14
+ import { tmpdir } from 'node:os';
13
15
 
14
16
  /**
15
17
  * Runner configuration schema and types.
@@ -45,6 +47,8 @@ const gatewaySchema = z.object({
45
47
  const runnerConfigSchema = z.object({
46
48
  /** HTTP server port for the runner API. */
47
49
  port: z.number().default(1937),
50
+ /** Bind address for the HTTP server. */
51
+ host: z.string().default('127.0.0.1'),
48
52
  /** Path to SQLite database file. */
49
53
  dbPath: z.string().default('./data/runner.sqlite'),
50
54
  /** Maximum number of concurrent job executions. */
@@ -91,8 +95,8 @@ const jobSchema = z.object({
91
95
  enabled: z.boolean().default(true),
92
96
  /** Optional execution timeout in milliseconds. */
93
97
  timeoutMs: z.number().optional(),
94
- /** Policy for handling overlapping job executions (skip, queue, or allow). */
95
- 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'),
96
100
  /** Slack channel ID for failure notifications. */
97
101
  onFailure: z.string().nullable().default(null),
98
102
  /** Slack channel ID for success notifications. */
@@ -170,6 +174,385 @@ const runSchema = z.object({
170
174
  trigger: runTriggerSchema.default('schedule'),
171
175
  });
172
176
 
177
+ /**
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
+
173
556
  /**
174
557
  * Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, system stats, and config queries.
175
558
  *
@@ -199,7 +582,7 @@ function registerRoutes(app, deps) {
199
582
  return { jobs: rows };
200
583
  });
201
584
  /** GET /jobs/:id — Single job detail. */
202
- app.get('/jobs/:id', async (request, reply) => {
585
+ app.get('/jobs/:id', (request, reply) => {
203
586
  const job = db
204
587
  .prepare('SELECT * FROM jobs WHERE id = ?')
205
588
  .get(request.params.id);
@@ -228,30 +611,6 @@ function registerRoutes(app, deps) {
228
611
  return { error: err instanceof Error ? err.message : 'Unknown error' };
229
612
  }
230
613
  });
231
- /** POST /jobs/:id/enable — Enable a job. */
232
- app.post('/jobs/:id/enable', (request, reply) => {
233
- const result = db
234
- .prepare('UPDATE jobs SET enabled = 1 WHERE id = ?')
235
- .run(request.params.id);
236
- if (result.changes === 0) {
237
- reply.code(404);
238
- return { error: 'Job not found' };
239
- }
240
- scheduler.reconcileNow();
241
- return { ok: true };
242
- });
243
- /** POST /jobs/:id/disable — Disable a job. */
244
- app.post('/jobs/:id/disable', (request, reply) => {
245
- const result = db
246
- .prepare('UPDATE jobs SET enabled = 0 WHERE id = ?')
247
- .run(request.params.id);
248
- if (result.changes === 0) {
249
- reply.code(404);
250
- return { error: 'Job not found' };
251
- }
252
- scheduler.reconcileNow();
253
- return { ok: true };
254
- });
255
614
  /** GET /stats — Aggregate job statistics. */
256
615
  app.get('/stats', () => {
257
616
  const totalJobs = db
@@ -283,10 +642,17 @@ function registerRoutes(app, deps) {
283
642
  });
284
643
  return reply.status(result.status).send(result.body);
285
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 });
286
650
  }
287
651
 
288
652
  /**
289
653
  * Fastify HTTP server for runner API. Creates server instance with logging, registers routes, listens on configured port (localhost only).
654
+ *
655
+ * @module
290
656
  */
291
657
  /**
292
658
  * Create and configure the Fastify server. Routes are registered but server is not started.
@@ -317,6 +683,8 @@ function createServer(deps) {
317
683
 
318
684
  /**
319
685
  * SQLite connection manager. Creates DB file with parent directories, enables WAL mode for concurrency.
686
+ *
687
+ * @module
320
688
  */
321
689
  /**
322
690
  * Create and configure a SQLite database connection.
@@ -345,6 +713,8 @@ function closeConnection(db) {
345
713
 
346
714
  /**
347
715
  * Database maintenance tasks: run retention pruning and expired state cleanup.
716
+ *
717
+ * @module
348
718
  */
349
719
  /** Delete runs older than the configured retention period. */
350
720
  function pruneOldRuns(db, days, logger) {
@@ -411,6 +781,8 @@ function createMaintenance(db, config, logger) {
411
781
 
412
782
  /**
413
783
  * Schema migration runner. Tracks applied migrations via schema_version table, applies pending migrations idempotently.
784
+ *
785
+ * @module
414
786
  */
415
787
  /** Initial schema migration SQL (embedded to avoid runtime file resolution issues). */
416
788
  const MIGRATION_001 = `
@@ -544,11 +916,16 @@ CREATE TABLE state_items (
544
916
  );
545
917
  CREATE INDEX idx_state_items_ns_key ON state_items(namespace, key);
546
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
+ `;
547
923
  /** Registry of all migrations keyed by version number. */
548
924
  const MIGRATIONS = {
549
925
  1: MIGRATION_001,
550
926
  2: MIGRATION_002,
551
927
  3: MIGRATION_003,
928
+ 4: MIGRATION_004,
552
929
  };
553
930
  /**
554
931
  * Run all pending migrations. Creates schema_version table if needed, applies migrations in order.
@@ -579,6 +956,8 @@ function runMigrations(db) {
579
956
 
580
957
  /**
581
958
  * Shared HTTP utility for making POST requests.
959
+ *
960
+ * @module
582
961
  */
583
962
  /** Make an HTTP/HTTPS POST request. Returns the parsed JSON response body. */
584
963
  function httpPost(url, headers, body, timeoutMs = 30000) {
@@ -626,6 +1005,8 @@ function httpPost(url, headers, body, timeoutMs = 30000) {
626
1005
 
627
1006
  /**
628
1007
  * OpenClaw Gateway HTTP client for spawning and monitoring sessions.
1008
+ *
1009
+ * @module
629
1010
  */
630
1011
  /** Make an HTTP POST request to the Gateway /tools/invoke endpoint. */
631
1012
  function invokeGateway(url, token, tool, args, timeoutMs = 30000) {
@@ -692,6 +1073,8 @@ function createGatewayClient(options) {
692
1073
 
693
1074
  /**
694
1075
  * Slack notification module. Sends job completion/failure messages via Slack Web API (chat.postMessage). Falls back gracefully if no token.
1076
+ *
1077
+ * @module
695
1078
  */
696
1079
  /** Post a message to Slack via chat.postMessage API. */
697
1080
  async function postToSlack(token, channel, text) {
@@ -709,21 +1092,21 @@ function createNotifier(config) {
709
1092
  return {
710
1093
  async notifySuccess(jobName, durationMs, channel) {
711
1094
  if (!slackToken) {
712
- console.warn(`No Slack token configured skipping success notification for ${jobName}`);
1095
+ console.warn(`No Slack token configured \u2014 skipping success notification for ${jobName}`);
713
1096
  return;
714
1097
  }
715
1098
  const durationSec = (durationMs / 1000).toFixed(1);
716
- const text = `?? *${jobName}* completed (${durationSec}s)`;
1099
+ const text = `\u2705 *${jobName}* completed (${durationSec}s)`;
717
1100
  await postToSlack(slackToken, channel, text);
718
1101
  },
719
1102
  async notifyFailure(jobName, durationMs, error, channel) {
720
1103
  if (!slackToken) {
721
- console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
1104
+ console.warn(`No Slack token configured \u2014 skipping failure notification for ${jobName}`);
722
1105
  return;
723
1106
  }
724
1107
  const durationSec = (durationMs / 1000).toFixed(1);
725
1108
  const errorMsg = error ? `: ${error}` : '';
726
- const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
1109
+ const text = `\u274c *${jobName}* failed (${durationSec}s)${errorMsg}`;
727
1110
  await postToSlack(slackToken, channel, text);
728
1111
  },
729
1112
  async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
@@ -743,6 +1126,8 @@ function createNotifier(config) {
743
1126
 
744
1127
  /**
745
1128
  * Job executor. Spawns job scripts as child processes, captures output, parses result metadata, enforces timeouts.
1129
+ *
1130
+ * @module
746
1131
  */
747
1132
  /** Ring buffer for capturing last N lines of output. */
748
1133
  class RingBuffer {
@@ -804,14 +1189,23 @@ function resolveCommand(script) {
804
1189
  * Execute a job script as a child process. Captures output, parses metadata, enforces timeout.
805
1190
  */
806
1191
  function executeJob(options) {
807
- const { script, dbPath, jobId, runId, timeoutMs, commandResolver } = options;
1192
+ const { script, dbPath, jobId, runId, timeoutMs, commandResolver, sourceType = 'path', } = options;
808
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
+ }
809
1203
  return new Promise((resolve) => {
810
1204
  const stdoutBuffer = new RingBuffer(100);
811
1205
  const stderrBuffer = new RingBuffer(100);
812
1206
  const { command, args } = commandResolver
813
- ? commandResolver(script)
814
- : resolveCommand(script);
1207
+ ? commandResolver(effectiveScript)
1208
+ : resolveCommand(effectiveScript);
815
1209
  const child = spawn(command, args, {
816
1210
  env: {
817
1211
  ...process.env,
@@ -847,6 +1241,7 @@ function executeJob(options) {
847
1241
  child.on('close', (exitCode) => {
848
1242
  if (timeoutHandle)
849
1243
  clearTimeout(timeoutHandle);
1244
+ cleanupTempFile(tempFile);
850
1245
  const durationMs = Date.now() - startTime;
851
1246
  const stdoutTail = stdoutBuffer.getAll();
852
1247
  const stderrTail = stderrBuffer.getAll();
@@ -891,6 +1286,7 @@ function executeJob(options) {
891
1286
  child.on('error', (err) => {
892
1287
  if (timeoutHandle)
893
1288
  clearTimeout(timeoutHandle);
1289
+ cleanupTempFile(tempFile);
894
1290
  const durationMs = Date.now() - startTime;
895
1291
  resolve({
896
1292
  status: 'error',
@@ -905,31 +1301,73 @@ function executeJob(options) {
905
1301
  });
906
1302
  });
907
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
+ }
908
1315
 
909
1316
  /**
910
- * 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
911
1320
  */
1321
+ /** Maximum setTimeout delay (Node.js limit: ~24.8 days). */
1322
+ const MAX_TIMEOUT_MS = 2_147_483_647;
912
1323
  function createCronRegistry(deps) {
913
1324
  const { db, logger, onScheduledRun } = deps;
914
- const crons = new Map();
915
- const cronSchedules = new Map();
1325
+ const handles = new Map();
1326
+ const scheduleStrings = new Map();
916
1327
  const failedRegistrations = new Set();
917
- 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) {
918
1368
  try {
919
- const jobId = job.id;
920
- const cron = new Cron(job.schedule, () => {
921
- // Re-read job from DB to get current configuration
922
- const currentJob = db
923
- .prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
924
- .get(jobId);
925
- if (!currentJob) {
926
- logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
927
- return;
928
- }
929
- onScheduledRun(currentJob);
930
- });
931
- crons.set(job.id, cron);
932
- cronSchedules.set(job.id, job.schedule);
1369
+ scheduleNext(job.id, job.schedule);
1370
+ scheduleStrings.set(job.id, job.schedule);
933
1371
  failedRegistrations.delete(job.id);
934
1372
  logger.info({ jobId: job.id, schedule: job.schedule }, 'Scheduled job');
935
1373
  return true;
@@ -946,39 +1384,39 @@ function createCronRegistry(deps) {
946
1384
  .all();
947
1385
  const enabledById = new Map(enabledJobs.map((j) => [j.id, j]));
948
1386
  // Remove disabled/deleted jobs
949
- for (const [jobId, cron] of crons.entries()) {
1387
+ for (const [jobId, handle] of handles.entries()) {
950
1388
  if (!enabledById.has(jobId)) {
951
- cron.stop();
952
- crons.delete(jobId);
953
- cronSchedules.delete(jobId);
1389
+ handle.cancel();
1390
+ handles.delete(jobId);
1391
+ scheduleStrings.delete(jobId);
954
1392
  }
955
1393
  }
956
1394
  const failedIds = [];
957
1395
  // Add or update enabled jobs
958
1396
  for (const job of enabledJobs) {
959
- const existingCron = crons.get(job.id);
960
- const existingSchedule = cronSchedules.get(job.id);
961
- if (!existingCron) {
962
- if (!registerCron(job))
1397
+ const existingHandle = handles.get(job.id);
1398
+ const existingSchedule = scheduleStrings.get(job.id);
1399
+ if (!existingHandle) {
1400
+ if (!registerSchedule(job))
963
1401
  failedIds.push(job.id);
964
1402
  continue;
965
1403
  }
966
1404
  if (existingSchedule !== job.schedule) {
967
- existingCron.stop();
968
- crons.delete(job.id);
969
- cronSchedules.delete(job.id);
970
- if (!registerCron(job))
1405
+ existingHandle.cancel();
1406
+ handles.delete(job.id);
1407
+ scheduleStrings.delete(job.id);
1408
+ if (!registerSchedule(job))
971
1409
  failedIds.push(job.id);
972
1410
  }
973
1411
  }
974
1412
  return { totalEnabled: enabledJobs.length, failedIds };
975
1413
  }
976
1414
  function stopAll() {
977
- for (const cron of crons.values()) {
978
- cron.stop();
1415
+ for (const handle of handles.values()) {
1416
+ handle.cancel();
979
1417
  }
980
- crons.clear();
981
- cronSchedules.clear();
1418
+ handles.clear();
1419
+ scheduleStrings.clear();
982
1420
  }
983
1421
  return {
984
1422
  reconcile,
@@ -991,6 +1429,8 @@ function createCronRegistry(deps) {
991
1429
 
992
1430
  /**
993
1431
  * Run record repository for managing job execution records.
1432
+ *
1433
+ * @module
994
1434
  */
995
1435
  /** Create a run repository for the given database connection. */
996
1436
  function createRunRepository(db) {
@@ -1012,6 +1452,8 @@ function createRunRepository(db) {
1012
1452
 
1013
1453
  /**
1014
1454
  * Session executor for job type='session'. Spawns OpenClaw Gateway sessions and polls for completion.
1455
+ *
1456
+ * @module
1015
1457
  */
1016
1458
  /** File extensions that indicate a script rather than a prompt. */
1017
1459
  const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ps1', '.cmd', '.bat'];
@@ -1111,7 +1553,9 @@ async function executeSession(options) {
1111
1553
  }
1112
1554
 
1113
1555
  /**
1114
- * 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
1115
1559
  */
1116
1560
  // JobRow is imported from cron-registry
1117
1561
  /**
@@ -1131,7 +1575,7 @@ function createScheduler(deps) {
1131
1575
  let reconcileInterval = null;
1132
1576
  /** Execute a job: create run record, run script, update record, send notifications. */
1133
1577
  async function runJob(job, trigger) {
1134
- 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;
1135
1579
  // Check concurrency limit
1136
1580
  if (runningJobs.size >= config.maxConcurrency) {
1137
1581
  logger.warn({ jobId: id }, 'Max concurrency reached, skipping job');
@@ -1162,6 +1606,7 @@ function createScheduler(deps) {
1162
1606
  jobId: id,
1163
1607
  runId,
1164
1608
  timeoutMs: timeout_ms ?? undefined,
1609
+ sourceType: source_type ?? 'path',
1165
1610
  });
1166
1611
  }
1167
1612
  runRepository.finishRun(runId, result);
@@ -1251,6 +1696,8 @@ function createScheduler(deps) {
1251
1696
 
1252
1697
  /**
1253
1698
  * Main runner orchestrator. Wires up database, scheduler, API server, and handles graceful shutdown on SIGTERM/SIGINT.
1699
+ *
1700
+ * @module
1254
1701
  */
1255
1702
  /**
1256
1703
  * Create the runner. Initializes database, scheduler, API server, and sets up graceful shutdown.
@@ -1322,7 +1769,7 @@ function createRunner(config, deps) {
1322
1769
  getConfig: () => config,
1323
1770
  loggerConfig: { level: config.log.level, file: config.log.file },
1324
1771
  });
1325
- await server.listen({ port: config.port, host: '127.0.0.1' });
1772
+ await server.listen({ port: config.port, host: config.host });
1326
1773
  logger.info({ port: config.port }, 'API server listening');
1327
1774
  // Graceful shutdown
1328
1775
  const shutdown = async (signal) => {
@@ -1361,6 +1808,8 @@ function createRunner(config, deps) {
1361
1808
 
1362
1809
  /**
1363
1810
  * Queue operations module for runner client. Provides enqueue, dequeue, done, and fail operations.
1811
+ *
1812
+ * @module
1364
1813
  */
1365
1814
  /** Create queue operations for the given database connection. */
1366
1815
  function createQueueOps(db) {
@@ -1463,6 +1912,8 @@ function createQueueOps(db) {
1463
1912
 
1464
1913
  /**
1465
1914
  * State operations module for runner client. Provides scalar state (key-value) and collection state (grouped items).
1915
+ *
1916
+ * @module
1466
1917
  */
1467
1918
  /** Parse TTL string (e.g., '30d', '24h', '60m') into ISO datetime offset from now. */
1468
1919
  function parseTtl(ttl) {
@@ -1568,6 +2019,8 @@ function createCollectionOps(db) {
1568
2019
 
1569
2020
  /**
1570
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
1571
2024
  */
1572
2025
  /**
1573
2026
  * Create a runner client for job scripts. Opens its own DB connection.
@@ -1590,4 +2043,4 @@ function createClient(dbPath) {
1590
2043
  };
1591
2044
  }
1592
2045
 
1593
- 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 };