@karmaniverous/jeeves-runner 0.2.1 → 0.3.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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, existsSync, readFileSync } from 'node:fs';
2
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, extname, resolve } from 'node:path';
4
4
  import { Command } from 'commander';
5
5
  import { Cron, CronPattern } from 'croner';
@@ -139,6 +139,9 @@ CREATE INDEX idx_queue_items_dedup ON queue_items(queue_id, dedup_key, status);
139
139
  -- Create new poll index
140
140
  CREATE INDEX idx_queue_items_poll ON queue_items(queue_id, status, priority DESC, created_at);
141
141
 
142
+ -- NOTE: These queue definitions are deployment-specific seeds. New installations
143
+ -- should define queues via the seed script or API, not here. These remain in the
144
+ -- migration for backward compatibility with existing databases.
142
145
  -- Seed queue definitions
143
146
  INSERT INTO queues (id, name, description, dedup_expr, dedup_scope, max_attempts, retention_days) VALUES
144
147
  ('email-updates', 'Email Update Queue', NULL, NULL, NULL, 1, 7),
@@ -333,7 +336,7 @@ function createServer(deps) {
333
336
  }
334
337
 
335
338
  /**
336
- * Database maintenance tasks: run retention pruning and expired cursor cleanup.
339
+ * Database maintenance tasks: run retention pruning and expired state cleanup.
337
340
  */
338
341
  /** Delete runs older than the configured retention period. */
339
342
  function pruneOldRuns(db, days, logger) {
@@ -346,7 +349,7 @@ function pruneOldRuns(db, days, logger) {
346
349
  }
347
350
  }
348
351
  /** Delete expired state entries. */
349
- function cleanExpiredCursors(db, logger) {
352
+ function cleanExpiredState(db, logger) {
350
353
  const result = db
351
354
  .prepare(`DELETE FROM state WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`)
352
355
  .run();
@@ -376,7 +379,7 @@ function createMaintenance(db, config, logger) {
376
379
  let interval = null;
377
380
  function runAll() {
378
381
  pruneOldRuns(db, config.runRetentionDays, logger);
379
- cleanExpiredCursors(db, logger);
382
+ cleanExpiredState(db, logger);
380
383
  pruneOldQueueItems(db, logger);
381
384
  }
382
385
  return {
@@ -384,7 +387,7 @@ function createMaintenance(db, config, logger) {
384
387
  // Run immediately on startup
385
388
  runAll();
386
389
  // Then run periodically
387
- interval = setInterval(runAll, config.cursorCleanupIntervalMs);
390
+ interval = setInterval(runAll, config.stateCleanupIntervalMs);
388
391
  },
389
392
  stop() {
390
393
  if (interval) {
@@ -483,7 +486,8 @@ function createGatewayClient(options) {
483
486
  return response.result;
484
487
  },
485
488
  async getSessionInfo(sessionKey) {
486
- // Note: sessions_list doesn't support filtering by key, so we fetch recent sessions
489
+ // TODO: Replace with a direct session lookup when Gateway adds sessions_get.
490
+ // Currently fetches up to 500 sessions and searches client-side � O(n) and
487
491
  // and search client-side. Consider using sessions_history with limit 1 as alternative,
488
492
  // or request a sessions_get tool from Gateway for more efficient single-session lookup.
489
493
  const response = (await invokeGateway(url, token, 'sessions_list', { activeMinutes: 120, limit: 500 }, // Increased from 100 to reduce false negatives
@@ -529,23 +533,35 @@ function createNotifier(config) {
529
533
  return {
530
534
  async notifySuccess(jobName, durationMs, channel) {
531
535
  if (!slackToken) {
532
- console.warn(`No Slack token configured skipping success notification for ${jobName}`);
536
+ console.warn(`No Slack token configured skipping success notification for ${jobName}`);
533
537
  return;
534
538
  }
535
539
  const durationSec = (durationMs / 1000).toFixed(1);
536
- const text = `✅ *${jobName}* completed (${durationSec}s)`;
540
+ const text = `?? *${jobName}* completed (${durationSec}s)`;
537
541
  await postToSlack(slackToken, channel, text);
538
542
  },
539
543
  async notifyFailure(jobName, durationMs, error, channel) {
540
544
  if (!slackToken) {
541
- console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
545
+ console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
542
546
  return;
543
547
  }
544
548
  const durationSec = (durationMs / 1000).toFixed(1);
545
549
  const errorMsg = error ? `: ${error}` : '';
546
- const text = `⚠️ *${jobName}* failed (${durationSec}s)${errorMsg}`;
550
+ const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
547
551
  await postToSlack(slackToken, channel, text);
548
552
  },
553
+ async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
554
+ if (result.status === 'ok' && onSuccess) {
555
+ await this.notifySuccess(jobName, result.durationMs, onSuccess).catch((err) => {
556
+ logger.error({ jobName, err }, 'Success notification failed');
557
+ });
558
+ }
559
+ else if (result.status !== 'ok' && onFailure) {
560
+ await this.notifyFailure(jobName, result.durationMs, result.error, onFailure).catch((err) => {
561
+ logger.error({ jobName, err }, 'Failure notification failed');
562
+ });
563
+ }
564
+ },
549
565
  };
550
566
  }
551
567
 
@@ -797,27 +813,6 @@ function createCronRegistry(deps) {
797
813
  };
798
814
  }
799
815
 
800
- /**
801
- * Notification dispatch helper for job completion events.
802
- */
803
- /** Dispatch notification based on execution result and job configuration. */
804
- async function dispatchNotification(result, jobName, onSuccess, onFailure, notifier, logger) {
805
- if (result.status === 'ok' && onSuccess) {
806
- await notifier
807
- .notifySuccess(jobName, result.durationMs, onSuccess)
808
- .catch((err) => {
809
- logger.error({ jobName, err }, 'Success notification failed');
810
- });
811
- }
812
- else if (result.status !== 'ok' && onFailure) {
813
- await notifier
814
- .notifyFailure(jobName, result.durationMs, result.error, onFailure)
815
- .catch((err) => {
816
- logger.error({ jobName, err }, 'Failure notification failed');
817
- });
818
- }
819
- }
820
-
821
816
  /**
822
817
  * Run record repository for managing job execution records.
823
818
  */
@@ -996,7 +991,7 @@ function createScheduler(deps) {
996
991
  runRepository.finishRun(runId, result);
997
992
  logger.info({ jobId: id, runId, status: result.status }, 'Job finished');
998
993
  // Send notifications
999
- await dispatchNotification(result, name, on_success, on_failure, notifier, logger);
994
+ await notifier.dispatchResult(result, name, on_success, on_failure, logger);
1000
995
  return result;
1001
996
  }
1002
997
  finally {
@@ -1125,10 +1120,10 @@ function createRunner(config, deps) {
1125
1120
  if (gatewayClient) {
1126
1121
  logger.info('Gateway client initialized');
1127
1122
  }
1128
- // Maintenance (run retention pruning + cursor cleanup)
1123
+ // Maintenance (run retention pruning + state cleanup)
1129
1124
  maintenance = createMaintenance(db, {
1130
1125
  runRetentionDays: config.runRetentionDays,
1131
- cursorCleanupIntervalMs: config.cursorCleanupIntervalMs,
1126
+ stateCleanupIntervalMs: config.stateCleanupIntervalMs,
1132
1127
  }, logger);
1133
1128
  maintenance.start();
1134
1129
  logger.info('Maintenance tasks started');
@@ -1226,8 +1221,8 @@ const runnerConfigSchema = z.object({
1226
1221
  maxConcurrency: z.number().default(4),
1227
1222
  /** Number of days to retain completed run records. */
1228
1223
  runRetentionDays: z.number().default(30),
1229
- /** Interval in milliseconds for cursor cleanup task. */
1230
- cursorCleanupIntervalMs: z.number().default(3600000),
1224
+ /** Interval in milliseconds for expired state cleanup task. */
1225
+ stateCleanupIntervalMs: z.number().default(3600000),
1231
1226
  /** Grace period in milliseconds for shutdown completion. */
1232
1227
  shutdownGraceMs: z.number().default(30000),
1233
1228
  /** Interval in milliseconds for job reconciliation checks. */
@@ -1243,6 +1238,227 @@ const runnerConfigSchema = z.object({
1243
1238
  gateway: gatewaySchema.default({ url: 'http://127.0.0.1:18789' }),
1244
1239
  });
1245
1240
 
1241
+ /**
1242
+ * @module commands/config
1243
+ *
1244
+ * CLI commands: validate, init, config show.
1245
+ */
1246
+ /** Minimal starter config template. */
1247
+ const INIT_CONFIG_TEMPLATE = {
1248
+ port: 1937,
1249
+ dbPath: './data/runner.sqlite',
1250
+ maxConcurrency: 4,
1251
+ runRetentionDays: 30,
1252
+ log: {
1253
+ level: 'info',
1254
+ },
1255
+ notifications: {
1256
+ slackTokenPath: '',
1257
+ defaultOnFailure: null,
1258
+ defaultOnSuccess: null,
1259
+ },
1260
+ gateway: {
1261
+ url: 'http://127.0.0.1:18789',
1262
+ tokenPath: '',
1263
+ },
1264
+ };
1265
+ /** Register config-related commands on the CLI. */
1266
+ function registerConfigCommands(cli) {
1267
+ cli
1268
+ .command('validate')
1269
+ .description('Validate a configuration file against the schema')
1270
+ .requiredOption('-c, --config <path>', 'Path to configuration file')
1271
+ .action((options) => {
1272
+ try {
1273
+ const raw = readFileSync(resolve(options.config), 'utf-8');
1274
+ const parsed = JSON.parse(raw);
1275
+ const config = runnerConfigSchema.parse(parsed);
1276
+ console.log('✅ Config valid');
1277
+ console.log(` Port: ${String(config.port)}`);
1278
+ console.log(` Database: ${config.dbPath}`);
1279
+ console.log(` Max concurrency: ${String(config.maxConcurrency)}`);
1280
+ console.log(` Run retention: ${String(config.runRetentionDays)} days`);
1281
+ console.log(` Log level: ${config.log.level}`);
1282
+ if (config.log.file) {
1283
+ console.log(` Log file: ${config.log.file}`);
1284
+ }
1285
+ if (config.notifications.slackTokenPath) {
1286
+ console.log(` Slack notifications: ${config.notifications.defaultOnFailure ? 'configured' : 'token set, no default channel'}`);
1287
+ }
1288
+ if (config.gateway.tokenPath) {
1289
+ console.log(` Gateway: ${config.gateway.url}`);
1290
+ }
1291
+ }
1292
+ catch (error) {
1293
+ if (error instanceof SyntaxError) {
1294
+ console.error(`❌ Invalid JSON: ${error.message}`);
1295
+ }
1296
+ else {
1297
+ console.error('❌ Config invalid:', error);
1298
+ }
1299
+ process.exit(1);
1300
+ }
1301
+ });
1302
+ cli
1303
+ .command('init')
1304
+ .description('Generate a starter configuration file')
1305
+ .option('-o, --output <path>', 'Output config file path', 'jeeves-runner.config.json')
1306
+ .action((options) => {
1307
+ const outputPath = resolve(options.output);
1308
+ try {
1309
+ readFileSync(outputPath);
1310
+ console.error(`❌ File already exists: ${outputPath}`);
1311
+ console.error(' Remove it first or choose a different path with -o');
1312
+ process.exit(1);
1313
+ }
1314
+ catch {
1315
+ // File doesn't exist, good
1316
+ }
1317
+ writeFileSync(outputPath, JSON.stringify(INIT_CONFIG_TEMPLATE, null, 2) + '\n');
1318
+ console.log(`✅ Wrote ${outputPath}`);
1319
+ console.log();
1320
+ console.log('Next steps:');
1321
+ console.log(' 1. Edit the config file to set your paths and preferences');
1322
+ console.log(' 2. Validate: jeeves-runner validate -c ' + options.output);
1323
+ console.log(' 3. Start: jeeves-runner start -c ' + options.output);
1324
+ });
1325
+ cli
1326
+ .command('config-show')
1327
+ .description('Show the resolved configuration (defaults applied, secrets redacted)')
1328
+ .requiredOption('-c, --config <path>', 'Path to configuration file')
1329
+ .action((options) => {
1330
+ try {
1331
+ const raw = readFileSync(resolve(options.config), 'utf-8');
1332
+ const parsed = JSON.parse(raw);
1333
+ const config = runnerConfigSchema.parse(parsed);
1334
+ // Redact sensitive paths
1335
+ const redacted = {
1336
+ ...config,
1337
+ notifications: {
1338
+ ...config.notifications,
1339
+ slackTokenPath: config.notifications.slackTokenPath
1340
+ ? '***'
1341
+ : undefined,
1342
+ },
1343
+ gateway: {
1344
+ ...config.gateway,
1345
+ tokenPath: config.gateway.tokenPath ? '***' : undefined,
1346
+ },
1347
+ };
1348
+ console.log(JSON.stringify(redacted, null, 2));
1349
+ }
1350
+ catch (error) {
1351
+ if (error instanceof SyntaxError) {
1352
+ console.error(`❌ Invalid JSON: ${error.message}`);
1353
+ }
1354
+ else {
1355
+ console.error('❌ Config invalid:', error);
1356
+ }
1357
+ process.exit(1);
1358
+ }
1359
+ });
1360
+ }
1361
+
1362
+ /**
1363
+ * @module commands/service
1364
+ *
1365
+ * CLI command: service install/uninstall.
1366
+ * Prints platform-appropriate service registration instructions.
1367
+ */
1368
+ /** Register the `service` command group on the CLI. */
1369
+ function registerServiceCommand(cli) {
1370
+ const service = cli
1371
+ .command('service')
1372
+ .description('Generate service install/uninstall instructions');
1373
+ service.addCommand(new Command('install')
1374
+ .description('Print install instructions for a system service')
1375
+ .option('-c, --config <path>', 'Path to configuration file')
1376
+ .option('-n, --name <name>', 'Service name', 'jeeves-runner')
1377
+ .action((options) => {
1378
+ const { name } = options;
1379
+ const configFlag = options.config ? ` -c "${options.config}"` : '';
1380
+ if (process.platform === 'win32') {
1381
+ console.log('# NSSM install (Windows)');
1382
+ console.log(` nssm install ${name} node "%APPDATA%\\npm\\node_modules\\@karmaniverous\\jeeves-runner\\dist\\cli\\jeeves-runner\\index.js" start${configFlag}`);
1383
+ console.log(` nssm set ${name} AppDirectory "%CD%"`);
1384
+ console.log(` nssm set ${name} DisplayName "Jeeves Runner"`);
1385
+ console.log(` nssm set ${name} Description "Job execution engine with SQLite state"`);
1386
+ console.log(` nssm set ${name} Start SERVICE_AUTO_START`);
1387
+ console.log(` nssm start ${name}`);
1388
+ return;
1389
+ }
1390
+ if (process.platform === 'darwin') {
1391
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
1392
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1393
+ <plist version="1.0">
1394
+ <dict>
1395
+ <key>Label</key><string>com.jeeves.runner</string>
1396
+ <key>ProgramArguments</key>
1397
+ <array>
1398
+ <string>/usr/local/bin/jeeves-runner</string>
1399
+ <string>start</string>${options.config ? `\n <string>-c</string>\n <string>${options.config}</string>` : ''}
1400
+ </array>
1401
+ <key>RunAtLoad</key><true/>
1402
+ <key>KeepAlive</key><true/>
1403
+ <key>StandardOutPath</key><string>/tmp/${name}.stdout.log</string>
1404
+ <key>StandardErrorPath</key><string>/tmp/${name}.stderr.log</string>
1405
+ </dict>
1406
+ </plist>`;
1407
+ console.log('# launchd plist (macOS)');
1408
+ console.log(`# ~/Library/LaunchAgents/com.jeeves.runner.plist`);
1409
+ console.log(plist);
1410
+ console.log();
1411
+ console.log('# install');
1412
+ console.log(` launchctl load ~/Library/LaunchAgents/com.jeeves.runner.plist`);
1413
+ return;
1414
+ }
1415
+ // Linux (systemd)
1416
+ const unit = [
1417
+ '[Unit]',
1418
+ 'Description=Jeeves Runner - Job Execution Engine',
1419
+ 'After=network.target',
1420
+ '',
1421
+ '[Service]',
1422
+ 'Type=simple',
1423
+ 'WorkingDirectory=%h',
1424
+ `ExecStart=/usr/bin/env jeeves-runner start${configFlag}`,
1425
+ 'Restart=on-failure',
1426
+ '',
1427
+ '[Install]',
1428
+ 'WantedBy=default.target',
1429
+ ].join('\n');
1430
+ console.log('# systemd unit file (Linux)');
1431
+ console.log(`# ~/.config/systemd/user/${name}.service`);
1432
+ console.log(unit);
1433
+ console.log();
1434
+ console.log('# install');
1435
+ console.log(` systemctl --user daemon-reload`);
1436
+ console.log(` systemctl --user enable --now ${name}.service`);
1437
+ }));
1438
+ service.addCommand(new Command('uninstall')
1439
+ .description('Print uninstall instructions for a system service')
1440
+ .option('-n, --name <name>', 'Service name', 'jeeves-runner')
1441
+ .action((options) => {
1442
+ const { name } = options;
1443
+ if (process.platform === 'win32') {
1444
+ console.log('# NSSM uninstall (Windows)');
1445
+ console.log(` nssm stop ${name}`);
1446
+ console.log(` nssm remove ${name} confirm`);
1447
+ return;
1448
+ }
1449
+ if (process.platform === 'darwin') {
1450
+ console.log('# launchd uninstall (macOS)');
1451
+ console.log(` launchctl unload ~/Library/LaunchAgents/com.jeeves.runner.plist`);
1452
+ console.log(` rm ~/Library/LaunchAgents/com.jeeves.runner.plist`);
1453
+ return;
1454
+ }
1455
+ console.log('# systemd uninstall (Linux)');
1456
+ console.log(` systemctl --user disable --now ${name}.service`);
1457
+ console.log(`# rm ~/.config/systemd/user/${name}.service`);
1458
+ console.log(` systemctl --user daemon-reload`);
1459
+ }));
1460
+ }
1461
+
1246
1462
  /**
1247
1463
  * CLI entry point for jeeves-runner.
1248
1464
  *
@@ -1370,4 +1586,6 @@ program
1370
1586
  }
1371
1587
  })();
1372
1588
  });
1589
+ registerConfigCommands(program);
1590
+ registerServiceCommand(program);
1373
1591
  program.parse();
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ declare const runnerConfigSchema: z.ZodObject<{
14
14
  dbPath: z.ZodDefault<z.ZodString>;
15
15
  maxConcurrency: z.ZodDefault<z.ZodNumber>;
16
16
  runRetentionDays: z.ZodDefault<z.ZodNumber>;
17
- cursorCleanupIntervalMs: z.ZodDefault<z.ZodNumber>;
17
+ stateCleanupIntervalMs: z.ZodDefault<z.ZodNumber>;
18
18
  shutdownGraceMs: z.ZodDefault<z.ZodNumber>;
19
19
  reconcileIntervalMs: z.ZodDefault<z.ZodNumber>;
20
20
  notifications: z.ZodDefault<z.ZodObject<{
@@ -183,26 +183,18 @@ interface QueueItem {
183
183
  }
184
184
 
185
185
  /**
186
- * Job client library for runner jobs. Provides cursor (state) and queue operations. Opens its own DB connection via JR_DB_PATH env var.
186
+ * Job client library for runner jobs. Provides state and queue operations. Opens its own DB connection via JR_DB_PATH env var.
187
187
  */
188
188
 
189
189
  /** Client interface for job scripts to interact with runner state and queues. */
190
190
  interface RunnerClient {
191
- /** Retrieve a cursor value by namespace and key. Returns null if not found or expired. */
192
- getCursor(namespace: string, key: string): string | null;
193
- /** Set or update a cursor value with optional TTL (e.g., '30d', '24h', '60m'). */
194
- setCursor(namespace: string, key: string, value: string, options?: {
195
- ttl?: string;
196
- }): void;
197
- /** Delete a cursor by namespace and key. */
198
- deleteCursor(namespace: string, key: string): void;
199
- /** Retrieve a state value by namespace and key (alias for getCursor). Returns null if not found or expired. */
191
+ /** Retrieve a state value by namespace and key. Returns null if not found or expired. */
200
192
  getState(namespace: string, key: string): string | null;
201
- /** Set or update a state value with optional TTL (alias for setCursor). */
193
+ /** Set or update a state value with optional TTL (e.g., '30d', '24h', '60m'). */
202
194
  setState(namespace: string, key: string, value: string, options?: {
203
195
  ttl?: string;
204
196
  }): void;
205
- /** Delete a state value by namespace and key (alias for deleteCursor). */
197
+ /** Delete a state value by namespace and key. */
206
198
  deleteState(namespace: string, key: string): void;
207
199
  /** Check if a state item exists in a collection. */
208
200
  hasItem(namespace: string, key: string, itemKey: string): boolean;
@@ -359,12 +351,23 @@ interface NotifyConfig {
359
351
  /** Slack bot token for posting messages (null if not configured). */
360
352
  slackToken: string | null;
361
353
  }
354
+ /** Logger subset used by the notifier. */
355
+ interface NotifyLogger {
356
+ /** Log an error with structured context. */
357
+ error(obj: Record<string, unknown>, msg: string): void;
358
+ }
362
359
  /** Notifier interface for job completion events. */
363
360
  interface Notifier {
364
361
  /** Send a success notification to a Slack channel. */
365
362
  notifySuccess(jobName: string, durationMs: number, channel: string): Promise<void>;
366
363
  /** Send a failure notification to a Slack channel. */
367
364
  notifyFailure(jobName: string, durationMs: number, error: string | null, channel: string): Promise<void>;
365
+ /** Dispatch notification based on execution result and per-job channel config. */
366
+ dispatchResult(result: {
367
+ status: string;
368
+ durationMs: number;
369
+ error: string | null;
370
+ }, jobName: string, onSuccess: string | null, onFailure: string | null, logger: NotifyLogger): Promise<void>;
368
371
  }
369
372
  /**
370
373
  * Create a notifier that sends Slack messages for job events. If no token, logs warning and returns silently.
@@ -447,7 +450,7 @@ declare function createConnection(dbPath: string): DatabaseSync;
447
450
  declare function closeConnection(db: DatabaseSync): void;
448
451
 
449
452
  /**
450
- * Database maintenance tasks: run retention pruning and expired cursor cleanup.
453
+ * Database maintenance tasks: run retention pruning and expired state cleanup.
451
454
  */
452
455
 
453
456
  /** Configuration for maintenance tasks. */
@@ -455,7 +458,7 @@ interface MaintenanceConfig {
455
458
  /** Number of days to retain completed run records before pruning. */
456
459
  runRetentionDays: number;
457
460
  /** Interval in milliseconds between maintenance task runs. */
458
- cursorCleanupIntervalMs: number;
461
+ stateCleanupIntervalMs: number;
459
462
  }
460
463
  /** Maintenance controller with start/stop lifecycle. */
461
464
  interface Maintenance {
@@ -481,4 +484,4 @@ declare function createMaintenance(db: DatabaseSync, config: MaintenanceConfig,
481
484
  declare function runMigrations(db: DatabaseSync): void;
482
485
 
483
486
  export { closeConnection, createClient, createConnection, createGatewayClient, createMaintenance, createNotifier, createRunner, createScheduler, executeJob, executeSession, jobSchema, queueSchema, runMigrations, runSchema, runStatusSchema, runTriggerSchema, runnerConfigSchema };
484
- export type { ExecutionOptions, ExecutionResult, GatewayClient, GatewayClientOptions, Job, Maintenance, MaintenanceConfig, Notifier, NotifyConfig, Queue, QueueItem, Run, RunStatus, RunTrigger, Runner, RunnerClient, RunnerConfig, Scheduler, SchedulerDeps, SessionExecutionOptions, SessionInfo, SessionMessage, SpawnSessionOptions, SpawnSessionResult };
487
+ export type { ExecutionOptions, ExecutionResult, GatewayClient, GatewayClientOptions, Job, Maintenance, MaintenanceConfig, Notifier, NotifyConfig, NotifyLogger, Queue, QueueItem, ResolvedCommand, Run, RunStatus, RunTrigger, Runner, RunnerClient, RunnerConfig, RunnerDeps, Scheduler, SchedulerDeps, SessionExecutionOptions, SessionInfo, SessionMessage, SpawnSessionOptions, SpawnSessionResult };
package/dist/mjs/index.js CHANGED
@@ -50,8 +50,8 @@ const runnerConfigSchema = z.object({
50
50
  maxConcurrency: z.number().default(4),
51
51
  /** Number of days to retain completed run records. */
52
52
  runRetentionDays: z.number().default(30),
53
- /** Interval in milliseconds for cursor cleanup task. */
54
- cursorCleanupIntervalMs: z.number().default(3600000),
53
+ /** Interval in milliseconds for expired state cleanup task. */
54
+ stateCleanupIntervalMs: z.number().default(3600000),
55
55
  /** Grace period in milliseconds for shutdown completion. */
56
56
  shutdownGraceMs: z.number().default(30000),
57
57
  /** Interval in milliseconds for job reconciliation checks. */
@@ -326,7 +326,7 @@ function closeConnection(db) {
326
326
  }
327
327
 
328
328
  /**
329
- * Database maintenance tasks: run retention pruning and expired cursor cleanup.
329
+ * Database maintenance tasks: run retention pruning and expired state cleanup.
330
330
  */
331
331
  /** Delete runs older than the configured retention period. */
332
332
  function pruneOldRuns(db, days, logger) {
@@ -339,7 +339,7 @@ function pruneOldRuns(db, days, logger) {
339
339
  }
340
340
  }
341
341
  /** Delete expired state entries. */
342
- function cleanExpiredCursors(db, logger) {
342
+ function cleanExpiredState(db, logger) {
343
343
  const result = db
344
344
  .prepare(`DELETE FROM state WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`)
345
345
  .run();
@@ -369,7 +369,7 @@ function createMaintenance(db, config, logger) {
369
369
  let interval = null;
370
370
  function runAll() {
371
371
  pruneOldRuns(db, config.runRetentionDays, logger);
372
- cleanExpiredCursors(db, logger);
372
+ cleanExpiredState(db, logger);
373
373
  pruneOldQueueItems(db, logger);
374
374
  }
375
375
  return {
@@ -377,7 +377,7 @@ function createMaintenance(db, config, logger) {
377
377
  // Run immediately on startup
378
378
  runAll();
379
379
  // Then run periodically
380
- interval = setInterval(runAll, config.cursorCleanupIntervalMs);
380
+ interval = setInterval(runAll, config.stateCleanupIntervalMs);
381
381
  },
382
382
  stop() {
383
383
  if (interval) {
@@ -494,6 +494,9 @@ CREATE INDEX idx_queue_items_dedup ON queue_items(queue_id, dedup_key, status);
494
494
  -- Create new poll index
495
495
  CREATE INDEX idx_queue_items_poll ON queue_items(queue_id, status, priority DESC, created_at);
496
496
 
497
+ -- NOTE: These queue definitions are deployment-specific seeds. New installations
498
+ -- should define queues via the seed script or API, not here. These remain in the
499
+ -- migration for backward compatibility with existing databases.
497
500
  -- Seed queue definitions
498
501
  INSERT INTO queues (id, name, description, dedup_expr, dedup_scope, max_attempts, retention_days) VALUES
499
502
  ('email-updates', 'Email Update Queue', NULL, NULL, NULL, 1, 7),
@@ -641,7 +644,8 @@ function createGatewayClient(options) {
641
644
  return response.result;
642
645
  },
643
646
  async getSessionInfo(sessionKey) {
644
- // Note: sessions_list doesn't support filtering by key, so we fetch recent sessions
647
+ // TODO: Replace with a direct session lookup when Gateway adds sessions_get.
648
+ // Currently fetches up to 500 sessions and searches client-side � O(n) and
645
649
  // and search client-side. Consider using sessions_history with limit 1 as alternative,
646
650
  // or request a sessions_get tool from Gateway for more efficient single-session lookup.
647
651
  const response = (await invokeGateway(url, token, 'sessions_list', { activeMinutes: 120, limit: 500 }, // Increased from 100 to reduce false negatives
@@ -687,23 +691,35 @@ function createNotifier(config) {
687
691
  return {
688
692
  async notifySuccess(jobName, durationMs, channel) {
689
693
  if (!slackToken) {
690
- console.warn(`No Slack token configured skipping success notification for ${jobName}`);
694
+ console.warn(`No Slack token configured skipping success notification for ${jobName}`);
691
695
  return;
692
696
  }
693
697
  const durationSec = (durationMs / 1000).toFixed(1);
694
- const text = `✅ *${jobName}* completed (${durationSec}s)`;
698
+ const text = `?? *${jobName}* completed (${durationSec}s)`;
695
699
  await postToSlack(slackToken, channel, text);
696
700
  },
697
701
  async notifyFailure(jobName, durationMs, error, channel) {
698
702
  if (!slackToken) {
699
- console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
703
+ console.warn(`No Slack token configured skipping failure notification for ${jobName}`);
700
704
  return;
701
705
  }
702
706
  const durationSec = (durationMs / 1000).toFixed(1);
703
707
  const errorMsg = error ? `: ${error}` : '';
704
- const text = `⚠️ *${jobName}* failed (${durationSec}s)${errorMsg}`;
708
+ const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
705
709
  await postToSlack(slackToken, channel, text);
706
710
  },
711
+ async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
712
+ if (result.status === 'ok' && onSuccess) {
713
+ await this.notifySuccess(jobName, result.durationMs, onSuccess).catch((err) => {
714
+ logger.error({ jobName, err }, 'Success notification failed');
715
+ });
716
+ }
717
+ else if (result.status !== 'ok' && onFailure) {
718
+ await this.notifyFailure(jobName, result.durationMs, result.error, onFailure).catch((err) => {
719
+ logger.error({ jobName, err }, 'Failure notification failed');
720
+ });
721
+ }
722
+ },
707
723
  };
708
724
  }
709
725
 
@@ -955,27 +971,6 @@ function createCronRegistry(deps) {
955
971
  };
956
972
  }
957
973
 
958
- /**
959
- * Notification dispatch helper for job completion events.
960
- */
961
- /** Dispatch notification based on execution result and job configuration. */
962
- async function dispatchNotification(result, jobName, onSuccess, onFailure, notifier, logger) {
963
- if (result.status === 'ok' && onSuccess) {
964
- await notifier
965
- .notifySuccess(jobName, result.durationMs, onSuccess)
966
- .catch((err) => {
967
- logger.error({ jobName, err }, 'Success notification failed');
968
- });
969
- }
970
- else if (result.status !== 'ok' && onFailure) {
971
- await notifier
972
- .notifyFailure(jobName, result.durationMs, result.error, onFailure)
973
- .catch((err) => {
974
- logger.error({ jobName, err }, 'Failure notification failed');
975
- });
976
- }
977
- }
978
-
979
974
  /**
980
975
  * Run record repository for managing job execution records.
981
976
  */
@@ -1154,7 +1149,7 @@ function createScheduler(deps) {
1154
1149
  runRepository.finishRun(runId, result);
1155
1150
  logger.info({ jobId: id, runId, status: result.status }, 'Job finished');
1156
1151
  // Send notifications
1157
- await dispatchNotification(result, name, on_success, on_failure, notifier, logger);
1152
+ await notifier.dispatchResult(result, name, on_success, on_failure, logger);
1158
1153
  return result;
1159
1154
  }
1160
1155
  finally {
@@ -1284,10 +1279,10 @@ function createRunner(config, deps) {
1284
1279
  if (gatewayClient) {
1285
1280
  logger.info('Gateway client initialized');
1286
1281
  }
1287
- // Maintenance (run retention pruning + cursor cleanup)
1282
+ // Maintenance (run retention pruning + state cleanup)
1288
1283
  maintenance = createMaintenance(db, {
1289
1284
  runRetentionDays: config.runRetentionDays,
1290
- cursorCleanupIntervalMs: config.cursorCleanupIntervalMs,
1285
+ stateCleanupIntervalMs: config.stateCleanupIntervalMs,
1291
1286
  }, logger);
1292
1287
  maintenance.start();
1293
1288
  logger.info('Maintenance tasks started');
@@ -1479,7 +1474,7 @@ function parseTtl(ttl) {
1479
1474
  /** Create state operations for the given database connection. */
1480
1475
  function createStateOps(db) {
1481
1476
  return {
1482
- getCursor(namespace, key) {
1477
+ getState(namespace, key) {
1483
1478
  const row = db
1484
1479
  .prepare(`SELECT value FROM state
1485
1480
  WHERE namespace = ? AND key = ?
@@ -1487,7 +1482,7 @@ function createStateOps(db) {
1487
1482
  .get(namespace, key);
1488
1483
  return row?.value ?? null;
1489
1484
  },
1490
- setCursor(namespace, key, value, options) {
1485
+ setState(namespace, key, value, options) {
1491
1486
  const expiresAt = options?.ttl ? parseTtl(options.ttl) : null;
1492
1487
  if (expiresAt) {
1493
1488
  db.prepare(`INSERT INTO state (namespace, key, value, expires_at) VALUES (?, ?, ?, ?)
@@ -1498,17 +1493,8 @@ function createStateOps(db) {
1498
1493
  ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`).run(namespace, key, value);
1499
1494
  }
1500
1495
  },
1501
- deleteCursor(namespace, key) {
1502
- db.prepare('DELETE FROM state WHERE namespace = ? AND key = ?').run(namespace, key);
1503
- },
1504
- getState(namespace, key) {
1505
- return this.getCursor(namespace, key);
1506
- },
1507
- setState(namespace, key, value, options) {
1508
- this.setCursor(namespace, key, value, options);
1509
- },
1510
1496
  deleteState(namespace, key) {
1511
- this.deleteCursor(namespace, key);
1497
+ db.prepare('DELETE FROM state WHERE namespace = ? AND key = ?').run(namespace, key);
1512
1498
  },
1513
1499
  };
1514
1500
  }
@@ -1562,7 +1548,7 @@ function createCollectionOps(db) {
1562
1548
  }
1563
1549
 
1564
1550
  /**
1565
- * Job client library for runner jobs. Provides cursor (state) and queue operations. Opens its own DB connection via JR_DB_PATH env var.
1551
+ * Job client library for runner jobs. Provides state and queue operations. Opens its own DB connection via JR_DB_PATH env var.
1566
1552
  */
1567
1553
  /**
1568
1554
  * Create a runner client for job scripts. Opens its own DB connection.
package/package.json CHANGED
@@ -1,17 +1,50 @@
1
1
  {
2
+ "name": "@karmaniverous/jeeves-runner",
3
+ "version": "0.3.0",
2
4
  "author": "Jason Williscroft",
5
+ "description": "Graph-aware job execution engine with SQLite state. Part of the Jeeves platform.",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "module": "dist/mjs/index.js",
9
+ "types": "dist/index.d.ts",
3
10
  "bin": {
4
11
  "jeeves-runner": "./dist/cli/jeeves-runner/index.js"
5
12
  },
6
- "auto-changelog": {
7
- "output": "CHANGELOG.md",
8
- "unreleased": true,
9
- "commitLimit": false,
10
- "hideCredit": true
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/mjs/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/karmaniverous/jeeves-runner.git",
28
+ "directory": "packages/service"
11
29
  },
12
30
  "bugs": {
13
31
  "url": "https://github.com/karmaniverous/jeeves-runner/issues"
14
32
  },
33
+ "homepage": "https://github.com/karmaniverous/jeeves-runner#readme",
34
+ "keywords": [
35
+ "jeeves",
36
+ "job-scheduler",
37
+ "cron",
38
+ "sqlite",
39
+ "workflow",
40
+ "automation",
41
+ "graph",
42
+ "fastify",
43
+ "typescript"
44
+ ],
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
15
48
  "dependencies": {
16
49
  "commander": "^14.0.3",
17
50
  "croner": "^10.0.1",
@@ -20,78 +53,50 @@
20
53
  "pino": "^10.3.1",
21
54
  "zod": "^4.3.6"
22
55
  },
23
- "description": "Graph-aware job execution engine with SQLite state. Part of the Jeeves platform.",
24
56
  "devDependencies": {
25
57
  "@dotenvx/dotenvx": "^1.52.0",
26
- "@eslint/js": "^9.39.3",
27
58
  "@rollup/plugin-alias": "^6.0.0",
28
59
  "@rollup/plugin-commonjs": "^29.0.0",
29
60
  "@rollup/plugin-json": "^6.1.0",
30
61
  "@rollup/plugin-node-resolve": "^16.0.3",
31
62
  "@rollup/plugin-typescript": "^12.3.0",
32
63
  "@types/fs-extra": "^11.0.4",
33
- "@types/node": "^25.3.0",
64
+ "@types/node": "^25.3.3",
34
65
  "@vitest/coverage-v8": "^4.0.18",
35
- "@vitest/eslint-plugin": "^1.6.9",
36
66
  "auto-changelog": "^2.5.0",
37
67
  "cross-env": "^10.1.0",
38
- "eslint": "^9.39.3",
39
- "eslint-config-prettier": "^10.1.8",
40
- "eslint-plugin-prettier": "^5.5.5",
41
- "eslint-plugin-simple-import-sort": "^12.1.1",
42
- "eslint-plugin-tsdoc": "^0.5.0",
43
68
  "fs-extra": "^11.3.3",
44
69
  "happy-dom": "^20.7.0",
45
- "knip": "^5.85.0",
46
- "lefthook": "^2.1.1",
47
- "prettier": "^3.8.1",
48
70
  "release-it": "^19.2.4",
49
- "rimraf": "^6.1.3",
50
71
  "rollup": "^4.59.0",
51
72
  "rollup-plugin-copy": "^3.5.0",
52
73
  "rollup-plugin-dts": "^6.3.0",
53
74
  "tslib": "^2.8.1",
54
- "typedoc": "^0.28.17",
55
- "typedoc-plugin-mdn-links": "^5.1.1",
56
- "typedoc-plugin-replace-text": "^4.2.0",
57
- "typescript": "^5.9.3",
58
- "typescript-eslint": "^8.56.1",
59
75
  "vitest": "^4.0.18"
60
76
  },
61
- "engines": {
62
- "node": ">=20"
63
- },
64
- "exports": {
65
- ".": {
66
- "types": "./dist/index.d.ts",
67
- "default": "./dist/mjs/index.js"
68
- }
77
+ "scripts": {
78
+ "build": "rimraf dist && cross-env NO_COLOR=1 rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
79
+ "changelog": "auto-changelog",
80
+ "diagrams": "cd diagrams && plantuml -tpng -o ../assets -r .",
81
+ "knip": "knip",
82
+ "lint": "eslint .",
83
+ "lint:fix": "eslint --fix .",
84
+ "release": "dotenvx run -f .env.local -- release-it",
85
+ "release:pre": "dotenvx run -f .env.local -- release-it --no-git.requireBranch --github.prerelease --preRelease",
86
+ "test": "vitest run",
87
+ "typecheck": "tsc"
69
88
  },
70
- "files": [
71
- "dist"
72
- ],
73
- "homepage": "https://github.com/karmaniverous/jeeves-runner#readme",
74
- "keywords": [
75
- "jeeves",
76
- "job-scheduler",
77
- "cron",
78
- "sqlite",
79
- "workflow",
80
- "automation",
81
- "graph",
82
- "fastify",
83
- "typescript"
84
- ],
85
- "license": "BSD-3-Clause",
86
- "module": "dist/mjs/index.js",
87
- "name": "@karmaniverous/jeeves-runner",
88
- "publishConfig": {
89
- "access": "public"
89
+ "auto-changelog": {
90
+ "output": "CHANGELOG.md",
91
+ "unreleased": true,
92
+ "commitLimit": false,
93
+ "hideCredit": true
90
94
  },
91
95
  "release-it": {
92
96
  "git": {
93
97
  "changelog": "npx auto-changelog --unreleased-only --stdout --template https://raw.githubusercontent.com/release-it/release-it/main/templates/changelog-compact.hbs",
94
- "commitMessage": "chore: release v${version}",
98
+ "commitMessage": "chore: release @karmaniverous/jeeves-runner v${version}",
99
+ "tagName": "service/${version}",
95
100
  "requireBranch": "main"
96
101
  },
97
102
  "github": {
@@ -106,37 +111,16 @@
106
111
  ],
107
112
  "before:npm:release": [
108
113
  "npx auto-changelog -p",
109
- "npm run docs",
110
114
  "git add -A"
111
115
  ],
112
116
  "after:release": [
113
- "git switch -c release/${version}",
114
- "git push -u origin release/${version}",
117
+ "git switch -c release/service/${version}",
118
+ "git push -u origin release/service/${version}",
115
119
  "git switch ${branchName}"
116
120
  ]
117
121
  },
118
122
  "npm": {
119
123
  "publish": true
120
124
  }
121
- },
122
- "repository": {
123
- "type": "git",
124
- "url": "git+https://github.com/karmaniverous/jeeves-runner.git"
125
- },
126
- "scripts": {
127
- "build": "rimraf dist && cross-env NO_COLOR=1 rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
128
- "changelog": "auto-changelog",
129
- "diagrams": "cd diagrams/src && plantuml -tpng -o ../out -r .",
130
- "docs": "typedoc",
131
- "knip": "knip",
132
- "lint": "eslint .",
133
- "lint:fix": "eslint --fix .",
134
- "release": "dotenvx run -f .env.local -- release-it",
135
- "release:pre": "dotenvx run -f .env.local -- release-it --no-git.requireBranch --github.prerelease --preRelease",
136
- "test": "vitest run",
137
- "typecheck": "tsc"
138
- },
139
- "type": "module",
140
- "types": "dist/index.d.ts",
141
- "version": "0.2.1"
125
+ }
142
126
  }
package/LICENSE DELETED
@@ -1,28 +0,0 @@
1
- BSD 3-Clause License
2
-
3
- Copyright (c) 2025, Jason Williscroft
4
-
5
- Redistribution and use in source and binary forms, with or without
6
- modification, are permitted provided that the following conditions are met:
7
-
8
- 1. Redistributions of source code must retain the above copyright notice, this
9
- list of conditions and the following disclaimer.
10
-
11
- 2. Redistributions in binary form must reproduce the above copyright notice,
12
- this list of conditions and the following disclaimer in the documentation
13
- and/or other materials provided with the distribution.
14
-
15
- 3. Neither the name of the copyright holder nor the names of its
16
- contributors may be used to endorse or promote products derived from
17
- this software without specific prior written permission.
18
-
19
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
- AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
- SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
- CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
- OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md DELETED
@@ -1,401 +0,0 @@
1
- # jeeves-runner
2
-
3
- [![npm version](https://img.shields.io/npm/v/@karmaniverous/jeeves-runner.svg)](https://www.npmjs.com/package/@karmaniverous/jeeves-runner)
4
- ![Node Current](https://img.shields.io/node/v/@karmaniverous/jeeves-runner)
5
- [![license](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](https://github.com/karmaniverous/jeeves-runner/tree/main/LICENSE.md)
6
-
7
- Graph-aware job execution engine with SQLite state. Part of the [Jeeves platform](#the-jeeves-platform).
8
-
9
- ## What It Does
10
-
11
- jeeves-runner schedules and executes jobs, tracks their state in SQLite, and exposes status via a REST API. It replaces both n8n and Windows Task Scheduler as the substrate for data flow automation.
12
-
13
- **Key properties:**
14
-
15
- - **Domain-agnostic.** The runner knows graph primitives (source, sink, datastore, queue, process, auth), not business concepts. "Email polling" and "meeting extraction" are just jobs with scripts.
16
- - **SQLite-native.** Job definitions, run history, cursors, and queues live in a single SQLite file. No external database, no Redis.
17
- - **Zero new infrastructure.** One Node.js process, one SQLite file. Runs as a system service via NSSM (Windows) or systemd (Linux).
18
- - **Scripts as config.** Job scripts live outside the runner repo at configurable absolute paths. The runner is generic; the scripts are instance-specific.
19
-
20
- ## Architecture
21
-
22
- ```
23
- ┌─────────────────────────────────────────────────┐
24
- │ jeeves-runner │
25
- │ │
26
- │ ┌───────────┐ ┌──────────┐ ┌──────────────┐ │
27
- │ │ Scheduler │──│ Executor │──│ Notifier │ │
28
- │ │ (croner) │ │ (spawn) │ │ (Slack) │ │
29
- │ └───────────┘ └──────────┘ └──────────────┘ │
30
- │ │
31
- │ ┌───────────┐ ┌──────────┐ ┌──────────────┐ │
32
- │ │ SQLite │ │ REST API │ │ Maintenance │ │
33
- │ │ (DB) │ │(Fastify) │ │ (pruning) │ │
34
- │ └───────────┘ └──────────┘ └──────────────┘ │
35
- └─────────────────────────────────────────────────┘
36
- │ │
37
- ▼ ▼
38
- runner.sqlite localhost:3100
39
- ```
40
-
41
- ### Stack
42
-
43
- | Component | Technology |
44
- |-----------|-----------|
45
- | Runtime | Node.js v24+ (uses built-in `node:sqlite`) |
46
- | Scheduler | [croner](https://www.npmjs.com/package/croner) |
47
- | Database | SQLite via `node:sqlite` |
48
- | Process isolation | `child_process.spawn` |
49
- | HTTP API | [Fastify](https://fastify.dev/) |
50
- | Logging | [pino](https://getpino.io/) |
51
- | Config validation | [Zod](https://zod.dev/) |
52
-
53
- ## Installation
54
-
55
- ```bash
56
- npm install @karmaniverous/jeeves-runner
57
- ```
58
-
59
- Requires Node.js 24+ for `node:sqlite` support.
60
-
61
- ## Quick Start
62
-
63
- ### 1. Create a config file
64
-
65
- ```json
66
- {
67
- "port": 3100,
68
- "dbPath": "./data/runner.sqlite",
69
- "maxConcurrency": 4,
70
- "runRetentionDays": 30,
71
- "cursorCleanupIntervalMs": 3600000,
72
- "shutdownGraceMs": 30000,
73
- "notifications": {
74
- "slackTokenPath": "./credentials/slack-bot-token",
75
- "defaultOnFailure": "YOUR_SLACK_CHANNEL_ID",
76
- "defaultOnSuccess": null
77
- },
78
- "log": {
79
- "level": "info",
80
- "file": "./data/runner.log"
81
- }
82
- }
83
- ```
84
-
85
- ### 2. Start the runner
86
-
87
- ```bash
88
- npx jeeves-runner start --config ./config.json
89
- ```
90
-
91
- ### 3. Add a job
92
-
93
- ```bash
94
- npx jeeves-runner add-job \
95
- --id my-job \
96
- --name "My Job" \
97
- --schedule "*/5 * * * *" \
98
- --script /absolute/path/to/script.js \
99
- --config ./config.json
100
- ```
101
-
102
- ### 4. Check status
103
-
104
- ```bash
105
- npx jeeves-runner status --config ./config.json
106
- npx jeeves-runner list-jobs --config ./config.json
107
- ```
108
-
109
- ## CLI Commands
110
-
111
- | Command | Description |
112
- |---------|-------------|
113
- | `start` | Start the runner daemon |
114
- | `status` | Show runner stats (queries the HTTP API) |
115
- | `list-jobs` | List all configured jobs |
116
- | `add-job` | Add a new job to the database |
117
- | `trigger` | Manually trigger a job run (queries the HTTP API) |
118
-
119
- All commands accept `--config <path>` to specify the config file.
120
-
121
- ## HTTP API
122
-
123
- The runner exposes a REST API on `localhost` (not externally accessible by default).
124
-
125
- | Method | Path | Description |
126
- |--------|------|-------------|
127
- | `GET` | `/health` | Health check |
128
- | `GET` | `/jobs` | List all jobs with last run status |
129
- | `GET` | `/jobs/:id` | Single job detail |
130
- | `GET` | `/jobs/:id/runs` | Run history (paginated via `?limit=N`) |
131
- | `POST` | `/jobs/:id/run` | Trigger manual run |
132
- | `POST` | `/jobs/:id/enable` | Enable a job |
133
- | `POST` | `/jobs/:id/disable` | Disable a job |
134
- | `GET` | `/stats` | Aggregate stats (jobs ok/error/running counts) |
135
-
136
- ### Example response
137
-
138
- ```json
139
- // GET /jobs
140
- {
141
- "jobs": [
142
- {
143
- "id": "email-poll",
144
- "name": "Poll Email",
145
- "schedule": "*/11 * * * *",
146
- "enabled": 1,
147
- "last_status": "ok",
148
- "last_run": "2026-02-24T10:30:00"
149
- }
150
- ]
151
- }
152
- ```
153
-
154
- ## SQLite Schema
155
-
156
- Four tables manage all runner state:
157
-
158
- ### `jobs` — Job Definitions
159
-
160
- Each job has an ID, name, cron schedule, script path, and behavioral configuration.
161
-
162
- | Column | Type | Description |
163
- |--------|------|-------------|
164
- | `id` | TEXT PK | Job identifier (e.g. `email-poll`) |
165
- | `name` | TEXT | Human-readable name |
166
- | `schedule` | TEXT | Cron expression |
167
- | `script` | TEXT | Absolute path to script |
168
- | `type` | TEXT | `script` or `session` (LLM dispatcher) |
169
- | `enabled` | INTEGER | 1 = active, 0 = paused |
170
- | `timeout_ms` | INTEGER | Kill after this duration (null = no limit) |
171
- | `overlap_policy` | TEXT | `skip` (default), `queue`, or `allow` |
172
- | `on_failure` | TEXT | Slack channel ID for failure alerts |
173
- | `on_success` | TEXT | Slack channel ID for success alerts |
174
-
175
- ### `runs` — Run History
176
-
177
- Every execution is recorded with status, timing, output capture, and optional token tracking.
178
-
179
- | Column | Type | Description |
180
- |--------|------|-------------|
181
- | `id` | INTEGER PK | Auto-incrementing run ID |
182
- | `job_id` | TEXT FK | References `jobs.id` |
183
- | `status` | TEXT | `pending`, `running`, `ok`, `error`, `timeout`, `skipped` |
184
- | `duration_ms` | INTEGER | Wall-clock execution time |
185
- | `exit_code` | INTEGER | Process exit code |
186
- | `tokens` | INTEGER | LLM token count (session jobs only) |
187
- | `result_meta` | TEXT | JSON from `JR_RESULT:{json}` stdout lines |
188
- | `stdout_tail` | TEXT | Last 100 lines of stdout |
189
- | `stderr_tail` | TEXT | Last 100 lines of stderr |
190
- | `trigger` | TEXT | `schedule`, `manual`, or `retry` |
191
-
192
- Runs older than `runRetentionDays` are automatically pruned.
193
-
194
- ### `cursors` — Key-Value State
195
-
196
- General-purpose key-value store with optional TTL. Replaces JSONL registry files.
197
-
198
- | Column | Type | Description |
199
- |--------|------|-------------|
200
- | `namespace` | TEXT | Logical grouping (typically job ID) |
201
- | `key` | TEXT | State key |
202
- | `value` | TEXT | State value (string or JSON) |
203
- | `expires_at` | TEXT | Optional TTL (ISO timestamp, auto-cleaned) |
204
-
205
- ### `queues` — Work Queues
206
-
207
- Priority-ordered work queues with claim semantics. SQLite's serialized writes prevent double-claims.
208
-
209
- | Column | Type | Description |
210
- |--------|------|-------------|
211
- | `id` | INTEGER PK | Auto-incrementing item ID |
212
- | `queue` | TEXT | Queue name |
213
- | `payload` | TEXT | JSON blob |
214
- | `status` | TEXT | `pending`, `claimed`, `done`, `error` |
215
- | `priority` | INTEGER | Higher = more urgent |
216
- | `attempts` | INTEGER | Delivery attempt count |
217
- | `max_attempts` | INTEGER | Maximum retries |
218
-
219
- ## Job Scripts
220
-
221
- Jobs are plain Node.js scripts executed as child processes. The runner passes context via environment variables:
222
-
223
- | Variable | Description |
224
- |----------|-------------|
225
- | `JR_DB_PATH` | Path to the runner SQLite database |
226
- | `JR_JOB_ID` | ID of the current job |
227
- | `JR_RUN_ID` | ID of the current run |
228
-
229
- ### Structured output
230
-
231
- Scripts can emit structured results by writing a line to stdout:
232
-
233
- ```
234
- JR_RESULT:{"tokens":1500,"meta":"processed 42 items"}
235
- ```
236
-
237
- The runner parses this and stores the data in the `runs` table.
238
-
239
- ### Client library
240
-
241
- Job scripts can import the runner client for cursor and queue operations:
242
-
243
- ```typescript
244
- import { createClient } from '@karmaniverous/jeeves-runner';
245
-
246
- const jr = createClient(); // reads JR_DB_PATH from env
247
-
248
- // Cursors (key-value state)
249
- const lastId = jr.getCursor('email-poll', 'last_history_id');
250
- jr.setCursor('email-poll', 'last_history_id', newId);
251
- jr.setCursor('email-poll', `seen:${threadId}`, '1', { ttl: '30d' });
252
- jr.deleteCursor('email-poll', 'old_key');
253
-
254
- // Queues
255
- jr.enqueue('email-updates', { threadId, action: 'label' });
256
- const items = jr.dequeue('email-updates', 10); // claim up to 10
257
- jr.done(items[0].id);
258
- jr.fail(items[1].id, 'API error');
259
-
260
- jr.close();
261
- ```
262
-
263
- ## Job Lifecycle
264
-
265
- ```
266
- Cron fires
267
- → Check overlap policy (skip if running & policy = 'skip')
268
- → INSERT run (status = 'running')
269
- → spawn('node', [script], { env: JR_* })
270
- → Capture stdout/stderr (ring buffer, last 100 lines)
271
- → Parse JR_RESULT lines → extract tokens + result_meta
272
- → On timeout: kill process, status = 'timeout', notify
273
- → On exit 0: status = 'ok', notify if on_success configured
274
- → On exit ≠ 0: status = 'error', notify if on_failure configured
275
- ```
276
-
277
- ### Overlap policies
278
-
279
- | Policy | Behavior |
280
- |--------|----------|
281
- | `skip` | Don't start if already running (default) |
282
- | `queue` | Wait for current run to finish, then start |
283
- | `allow` | Run concurrently |
284
-
285
- ### Concurrency
286
-
287
- A global semaphore limits concurrent jobs (default: 4, configurable via `maxConcurrency`). When the limit is hit, behavior follows the job's overlap policy.
288
-
289
- ### Notifications
290
-
291
- Slack notifications are sent via direct HTTP POST to `chat.postMessage` (no SDK dependency):
292
-
293
- - **Failure:** `⚠️ *Job Name* failed (12.3s): error message`
294
- - **Success:** `✅ *Job Name* completed (3.4s)`
295
-
296
- Notifications require a Slack bot token (file path in config). Each job can override the default notification channels.
297
-
298
- ## Maintenance
299
-
300
- The runner automatically performs periodic maintenance:
301
-
302
- - **Run pruning:** Deletes run records older than `runRetentionDays` (default: 30).
303
- - **Cursor cleanup:** Deletes expired cursor entries (runs every `cursorCleanupIntervalMs`, default: 1 hour).
304
-
305
- Both tasks run on startup and at the configured interval.
306
-
307
- ## Programmatic Usage
308
-
309
- ```typescript
310
- import { createRunner, runnerConfigSchema } from '@karmaniverous/jeeves-runner';
311
-
312
- const config = runnerConfigSchema.parse({
313
- port: 3100,
314
- dbPath: './data/runner.sqlite',
315
- });
316
-
317
- const runner = createRunner(config);
318
- await runner.start();
319
-
320
- // Graceful shutdown
321
- process.on('SIGTERM', () => runner.stop());
322
- ```
323
-
324
- ## Configuration Reference
325
-
326
- | Key | Type | Default | Description |
327
- |-----|------|---------|-------------|
328
- | `port` | number | `3100` | HTTP API port |
329
- | `dbPath` | string | `./data/runner.sqlite` | SQLite database path |
330
- | `maxConcurrency` | number | `4` | Max concurrent jobs |
331
- | `runRetentionDays` | number | `30` | Days to keep run history |
332
- | `cursorCleanupIntervalMs` | number | `3600000` | Cursor cleanup interval (ms) |
333
- | `shutdownGraceMs` | number | `30000` | Grace period for running jobs on shutdown |
334
- | `notifications.slackTokenPath` | string | — | Path to Slack bot token file |
335
- | `notifications.defaultOnFailure` | string \| null | `null` | Default Slack channel for failures |
336
- | `notifications.defaultOnSuccess` | string \| null | `null` | Default Slack channel for successes |
337
- | `log.level` | string | `info` | Log level (trace/debug/info/warn/error/fatal) |
338
- | `log.file` | string | — | Log file path (stdout if omitted) |
339
-
340
- ## The Jeeves Platform
341
-
342
- jeeves-runner is one component of a four-part platform:
343
-
344
- | Component | Role | Status |
345
- |-----------|------|--------|
346
- | **jeeves-runner** | Execute: run processes, move data through the graph | This package |
347
- | **[jeeves-watcher](https://github.com/karmaniverous/jeeves-watcher)** | Index: observe file-backed datastores, embed in Qdrant | Shipped |
348
- | **jeeves-server** | Present: UI, API, file serving, search, dashboards | Shipped |
349
- | **Jeeves skill** | Converse: configure, operate, and query via chat | Planned |
350
-
351
- ## Project Status
352
-
353
- **Phase 1** (current): Replicate existing job scheduling and status reporting. Replace n8n and the Notion Process Dashboard.
354
-
355
- ### What's built
356
-
357
- - ✅ SQLite schema (jobs, runs, cursors, queues)
358
- - ✅ Cron scheduler with overlap policies and concurrency limits
359
- - ✅ Job executor with output capture, timeout enforcement, and `JR_RESULT` parsing
360
- - ✅ Client library for cursor/queue operations from job scripts
361
- - ✅ Slack notifications for job success/failure
362
- - ✅ REST API (Fastify) for job management and monitoring
363
- - ✅ CLI for daemon management and job operations
364
- - ✅ Maintenance tasks (run pruning, cursor cleanup)
365
- - ✅ Zod-validated configuration
366
- - ✅ Seed script for 27 existing n8n workflows
367
- - ✅ 75 passing tests
368
-
369
- ### What's next (Phase 1 remaining)
370
-
371
- - [ ] NSSM service setup
372
- - [ ] jeeves-server dashboard page (`/runner`)
373
- - [ ] Migrate jobs from n8n one by one
374
- - [ ] Retire n8n
375
-
376
- ### Future phases
377
-
378
- | Feature | Phase |
379
- |---------|-------|
380
- | Graph topology (nodes/edges schema) | 2 |
381
- | Credential/auth management | 2 |
382
- | REST API for graph mutations | 2 |
383
- | OpenClaw plugin & Jeeves skill | 3 |
384
- | Container packaging | 3 |
385
-
386
- ## Development
387
-
388
- ```bash
389
- npm install
390
- npx lefthook install
391
-
392
- npm run lint # ESLint + Prettier
393
- npm run test # Vitest
394
- npm run knip # Unused code detection
395
- npm run build # Rollup (ESM + types + CLI)
396
- npm run typecheck # TypeScript (noEmit)
397
- ```
398
-
399
- ## License
400
-
401
- BSD-3-Clause