@karmaniverous/jeeves-runner 0.2.1 → 0.3.1
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/cli/jeeves-runner/index.js +257 -36
- package/dist/index.d.ts +21 -18
- package/dist/mjs/index.js +37 -48
- package/package.json +60 -76
- package/LICENSE +0 -28
- package/README.md +0 -401
|
@@ -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';
|
|
@@ -27,6 +27,9 @@ function createConnection(dbPath) {
|
|
|
27
27
|
// Enable WAL mode for better concurrency
|
|
28
28
|
db.exec('PRAGMA journal_mode = WAL;');
|
|
29
29
|
db.exec('PRAGMA foreign_keys = ON;');
|
|
30
|
+
// Wait up to 5s for write lock instead of failing immediately (SQLITE_BUSY).
|
|
31
|
+
// Multiple runner child processes share this DB file concurrently.
|
|
32
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
30
33
|
return db;
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
@@ -139,6 +142,9 @@ CREATE INDEX idx_queue_items_dedup ON queue_items(queue_id, dedup_key, status);
|
|
|
139
142
|
-- Create new poll index
|
|
140
143
|
CREATE INDEX idx_queue_items_poll ON queue_items(queue_id, status, priority DESC, created_at);
|
|
141
144
|
|
|
145
|
+
-- NOTE: These queue definitions are deployment-specific seeds. New installations
|
|
146
|
+
-- should define queues via the seed script or API, not here. These remain in the
|
|
147
|
+
-- migration for backward compatibility with existing databases.
|
|
142
148
|
-- Seed queue definitions
|
|
143
149
|
INSERT INTO queues (id, name, description, dedup_expr, dedup_scope, max_attempts, retention_days) VALUES
|
|
144
150
|
('email-updates', 'Email Update Queue', NULL, NULL, NULL, 1, 7),
|
|
@@ -333,7 +339,7 @@ function createServer(deps) {
|
|
|
333
339
|
}
|
|
334
340
|
|
|
335
341
|
/**
|
|
336
|
-
* Database maintenance tasks: run retention pruning and expired
|
|
342
|
+
* Database maintenance tasks: run retention pruning and expired state cleanup.
|
|
337
343
|
*/
|
|
338
344
|
/** Delete runs older than the configured retention period. */
|
|
339
345
|
function pruneOldRuns(db, days, logger) {
|
|
@@ -346,7 +352,7 @@ function pruneOldRuns(db, days, logger) {
|
|
|
346
352
|
}
|
|
347
353
|
}
|
|
348
354
|
/** Delete expired state entries. */
|
|
349
|
-
function
|
|
355
|
+
function cleanExpiredState(db, logger) {
|
|
350
356
|
const result = db
|
|
351
357
|
.prepare(`DELETE FROM state WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`)
|
|
352
358
|
.run();
|
|
@@ -376,7 +382,7 @@ function createMaintenance(db, config, logger) {
|
|
|
376
382
|
let interval = null;
|
|
377
383
|
function runAll() {
|
|
378
384
|
pruneOldRuns(db, config.runRetentionDays, logger);
|
|
379
|
-
|
|
385
|
+
cleanExpiredState(db, logger);
|
|
380
386
|
pruneOldQueueItems(db, logger);
|
|
381
387
|
}
|
|
382
388
|
return {
|
|
@@ -384,7 +390,7 @@ function createMaintenance(db, config, logger) {
|
|
|
384
390
|
// Run immediately on startup
|
|
385
391
|
runAll();
|
|
386
392
|
// Then run periodically
|
|
387
|
-
interval = setInterval(runAll, config.
|
|
393
|
+
interval = setInterval(runAll, config.stateCleanupIntervalMs);
|
|
388
394
|
},
|
|
389
395
|
stop() {
|
|
390
396
|
if (interval) {
|
|
@@ -483,7 +489,8 @@ function createGatewayClient(options) {
|
|
|
483
489
|
return response.result;
|
|
484
490
|
},
|
|
485
491
|
async getSessionInfo(sessionKey) {
|
|
486
|
-
//
|
|
492
|
+
// TODO: Replace with a direct session lookup when Gateway adds sessions_get.
|
|
493
|
+
// Currently fetches up to 500 sessions and searches client-side � O(n) and
|
|
487
494
|
// and search client-side. Consider using sessions_history with limit 1 as alternative,
|
|
488
495
|
// or request a sessions_get tool from Gateway for more efficient single-session lookup.
|
|
489
496
|
const response = (await invokeGateway(url, token, 'sessions_list', { activeMinutes: 120, limit: 500 }, // Increased from 100 to reduce false negatives
|
|
@@ -529,23 +536,35 @@ function createNotifier(config) {
|
|
|
529
536
|
return {
|
|
530
537
|
async notifySuccess(jobName, durationMs, channel) {
|
|
531
538
|
if (!slackToken) {
|
|
532
|
-
console.warn(`No Slack token configured
|
|
539
|
+
console.warn(`No Slack token configured � skipping success notification for ${jobName}`);
|
|
533
540
|
return;
|
|
534
541
|
}
|
|
535
542
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
536
|
-
const text =
|
|
543
|
+
const text = `?? *${jobName}* completed (${durationSec}s)`;
|
|
537
544
|
await postToSlack(slackToken, channel, text);
|
|
538
545
|
},
|
|
539
546
|
async notifyFailure(jobName, durationMs, error, channel) {
|
|
540
547
|
if (!slackToken) {
|
|
541
|
-
console.warn(`No Slack token configured
|
|
548
|
+
console.warn(`No Slack token configured � skipping failure notification for ${jobName}`);
|
|
542
549
|
return;
|
|
543
550
|
}
|
|
544
551
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
545
552
|
const errorMsg = error ? `: ${error}` : '';
|
|
546
|
-
const text =
|
|
553
|
+
const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
|
|
547
554
|
await postToSlack(slackToken, channel, text);
|
|
548
555
|
},
|
|
556
|
+
async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
|
|
557
|
+
if (result.status === 'ok' && onSuccess) {
|
|
558
|
+
await this.notifySuccess(jobName, result.durationMs, onSuccess).catch((err) => {
|
|
559
|
+
logger.error({ jobName, err }, 'Success notification failed');
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
else if (result.status !== 'ok' && onFailure) {
|
|
563
|
+
await this.notifyFailure(jobName, result.durationMs, result.error, onFailure).catch((err) => {
|
|
564
|
+
logger.error({ jobName, err }, 'Failure notification failed');
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
},
|
|
549
568
|
};
|
|
550
569
|
}
|
|
551
570
|
|
|
@@ -797,27 +816,6 @@ function createCronRegistry(deps) {
|
|
|
797
816
|
};
|
|
798
817
|
}
|
|
799
818
|
|
|
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
819
|
/**
|
|
822
820
|
* Run record repository for managing job execution records.
|
|
823
821
|
*/
|
|
@@ -996,7 +994,7 @@ function createScheduler(deps) {
|
|
|
996
994
|
runRepository.finishRun(runId, result);
|
|
997
995
|
logger.info({ jobId: id, runId, status: result.status }, 'Job finished');
|
|
998
996
|
// Send notifications
|
|
999
|
-
await
|
|
997
|
+
await notifier.dispatchResult(result, name, on_success, on_failure, logger);
|
|
1000
998
|
return result;
|
|
1001
999
|
}
|
|
1002
1000
|
finally {
|
|
@@ -1125,10 +1123,10 @@ function createRunner(config, deps) {
|
|
|
1125
1123
|
if (gatewayClient) {
|
|
1126
1124
|
logger.info('Gateway client initialized');
|
|
1127
1125
|
}
|
|
1128
|
-
// Maintenance (run retention pruning +
|
|
1126
|
+
// Maintenance (run retention pruning + state cleanup)
|
|
1129
1127
|
maintenance = createMaintenance(db, {
|
|
1130
1128
|
runRetentionDays: config.runRetentionDays,
|
|
1131
|
-
|
|
1129
|
+
stateCleanupIntervalMs: config.stateCleanupIntervalMs,
|
|
1132
1130
|
}, logger);
|
|
1133
1131
|
maintenance.start();
|
|
1134
1132
|
logger.info('Maintenance tasks started');
|
|
@@ -1226,8 +1224,8 @@ const runnerConfigSchema = z.object({
|
|
|
1226
1224
|
maxConcurrency: z.number().default(4),
|
|
1227
1225
|
/** Number of days to retain completed run records. */
|
|
1228
1226
|
runRetentionDays: z.number().default(30),
|
|
1229
|
-
/** Interval in milliseconds for
|
|
1230
|
-
|
|
1227
|
+
/** Interval in milliseconds for expired state cleanup task. */
|
|
1228
|
+
stateCleanupIntervalMs: z.number().default(3600000),
|
|
1231
1229
|
/** Grace period in milliseconds for shutdown completion. */
|
|
1232
1230
|
shutdownGraceMs: z.number().default(30000),
|
|
1233
1231
|
/** Interval in milliseconds for job reconciliation checks. */
|
|
@@ -1243,6 +1241,227 @@ const runnerConfigSchema = z.object({
|
|
|
1243
1241
|
gateway: gatewaySchema.default({ url: 'http://127.0.0.1:18789' }),
|
|
1244
1242
|
});
|
|
1245
1243
|
|
|
1244
|
+
/**
|
|
1245
|
+
* @module commands/config
|
|
1246
|
+
*
|
|
1247
|
+
* CLI commands: validate, init, config show.
|
|
1248
|
+
*/
|
|
1249
|
+
/** Minimal starter config template. */
|
|
1250
|
+
const INIT_CONFIG_TEMPLATE = {
|
|
1251
|
+
port: 1937,
|
|
1252
|
+
dbPath: './data/runner.sqlite',
|
|
1253
|
+
maxConcurrency: 4,
|
|
1254
|
+
runRetentionDays: 30,
|
|
1255
|
+
log: {
|
|
1256
|
+
level: 'info',
|
|
1257
|
+
},
|
|
1258
|
+
notifications: {
|
|
1259
|
+
slackTokenPath: '',
|
|
1260
|
+
defaultOnFailure: null,
|
|
1261
|
+
defaultOnSuccess: null,
|
|
1262
|
+
},
|
|
1263
|
+
gateway: {
|
|
1264
|
+
url: 'http://127.0.0.1:18789',
|
|
1265
|
+
tokenPath: '',
|
|
1266
|
+
},
|
|
1267
|
+
};
|
|
1268
|
+
/** Register config-related commands on the CLI. */
|
|
1269
|
+
function registerConfigCommands(cli) {
|
|
1270
|
+
cli
|
|
1271
|
+
.command('validate')
|
|
1272
|
+
.description('Validate a configuration file against the schema')
|
|
1273
|
+
.requiredOption('-c, --config <path>', 'Path to configuration file')
|
|
1274
|
+
.action((options) => {
|
|
1275
|
+
try {
|
|
1276
|
+
const raw = readFileSync(resolve(options.config), 'utf-8');
|
|
1277
|
+
const parsed = JSON.parse(raw);
|
|
1278
|
+
const config = runnerConfigSchema.parse(parsed);
|
|
1279
|
+
console.log('✅ Config valid');
|
|
1280
|
+
console.log(` Port: ${String(config.port)}`);
|
|
1281
|
+
console.log(` Database: ${config.dbPath}`);
|
|
1282
|
+
console.log(` Max concurrency: ${String(config.maxConcurrency)}`);
|
|
1283
|
+
console.log(` Run retention: ${String(config.runRetentionDays)} days`);
|
|
1284
|
+
console.log(` Log level: ${config.log.level}`);
|
|
1285
|
+
if (config.log.file) {
|
|
1286
|
+
console.log(` Log file: ${config.log.file}`);
|
|
1287
|
+
}
|
|
1288
|
+
if (config.notifications.slackTokenPath) {
|
|
1289
|
+
console.log(` Slack notifications: ${config.notifications.defaultOnFailure ? 'configured' : 'token set, no default channel'}`);
|
|
1290
|
+
}
|
|
1291
|
+
if (config.gateway.tokenPath) {
|
|
1292
|
+
console.log(` Gateway: ${config.gateway.url}`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
catch (error) {
|
|
1296
|
+
if (error instanceof SyntaxError) {
|
|
1297
|
+
console.error(`❌ Invalid JSON: ${error.message}`);
|
|
1298
|
+
}
|
|
1299
|
+
else {
|
|
1300
|
+
console.error('❌ Config invalid:', error);
|
|
1301
|
+
}
|
|
1302
|
+
process.exit(1);
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
cli
|
|
1306
|
+
.command('init')
|
|
1307
|
+
.description('Generate a starter configuration file')
|
|
1308
|
+
.option('-o, --output <path>', 'Output config file path', 'jeeves-runner.config.json')
|
|
1309
|
+
.action((options) => {
|
|
1310
|
+
const outputPath = resolve(options.output);
|
|
1311
|
+
try {
|
|
1312
|
+
readFileSync(outputPath);
|
|
1313
|
+
console.error(`❌ File already exists: ${outputPath}`);
|
|
1314
|
+
console.error(' Remove it first or choose a different path with -o');
|
|
1315
|
+
process.exit(1);
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
// File doesn't exist, good
|
|
1319
|
+
}
|
|
1320
|
+
writeFileSync(outputPath, JSON.stringify(INIT_CONFIG_TEMPLATE, null, 2) + '\n');
|
|
1321
|
+
console.log(`✅ Wrote ${outputPath}`);
|
|
1322
|
+
console.log();
|
|
1323
|
+
console.log('Next steps:');
|
|
1324
|
+
console.log(' 1. Edit the config file to set your paths and preferences');
|
|
1325
|
+
console.log(' 2. Validate: jeeves-runner validate -c ' + options.output);
|
|
1326
|
+
console.log(' 3. Start: jeeves-runner start -c ' + options.output);
|
|
1327
|
+
});
|
|
1328
|
+
cli
|
|
1329
|
+
.command('config-show')
|
|
1330
|
+
.description('Show the resolved configuration (defaults applied, secrets redacted)')
|
|
1331
|
+
.requiredOption('-c, --config <path>', 'Path to configuration file')
|
|
1332
|
+
.action((options) => {
|
|
1333
|
+
try {
|
|
1334
|
+
const raw = readFileSync(resolve(options.config), 'utf-8');
|
|
1335
|
+
const parsed = JSON.parse(raw);
|
|
1336
|
+
const config = runnerConfigSchema.parse(parsed);
|
|
1337
|
+
// Redact sensitive paths
|
|
1338
|
+
const redacted = {
|
|
1339
|
+
...config,
|
|
1340
|
+
notifications: {
|
|
1341
|
+
...config.notifications,
|
|
1342
|
+
slackTokenPath: config.notifications.slackTokenPath
|
|
1343
|
+
? '***'
|
|
1344
|
+
: undefined,
|
|
1345
|
+
},
|
|
1346
|
+
gateway: {
|
|
1347
|
+
...config.gateway,
|
|
1348
|
+
tokenPath: config.gateway.tokenPath ? '***' : undefined,
|
|
1349
|
+
},
|
|
1350
|
+
};
|
|
1351
|
+
console.log(JSON.stringify(redacted, null, 2));
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
if (error instanceof SyntaxError) {
|
|
1355
|
+
console.error(`❌ Invalid JSON: ${error.message}`);
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
console.error('❌ Config invalid:', error);
|
|
1359
|
+
}
|
|
1360
|
+
process.exit(1);
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* @module commands/service
|
|
1367
|
+
*
|
|
1368
|
+
* CLI command: service install/uninstall.
|
|
1369
|
+
* Prints platform-appropriate service registration instructions.
|
|
1370
|
+
*/
|
|
1371
|
+
/** Register the `service` command group on the CLI. */
|
|
1372
|
+
function registerServiceCommand(cli) {
|
|
1373
|
+
const service = cli
|
|
1374
|
+
.command('service')
|
|
1375
|
+
.description('Generate service install/uninstall instructions');
|
|
1376
|
+
service.addCommand(new Command('install')
|
|
1377
|
+
.description('Print install instructions for a system service')
|
|
1378
|
+
.option('-c, --config <path>', 'Path to configuration file')
|
|
1379
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-runner')
|
|
1380
|
+
.action((options) => {
|
|
1381
|
+
const { name } = options;
|
|
1382
|
+
const configFlag = options.config ? ` -c "${options.config}"` : '';
|
|
1383
|
+
if (process.platform === 'win32') {
|
|
1384
|
+
console.log('# NSSM install (Windows)');
|
|
1385
|
+
console.log(` nssm install ${name} node "%APPDATA%\\npm\\node_modules\\@karmaniverous\\jeeves-runner\\dist\\cli\\jeeves-runner\\index.js" start${configFlag}`);
|
|
1386
|
+
console.log(` nssm set ${name} AppDirectory "%CD%"`);
|
|
1387
|
+
console.log(` nssm set ${name} DisplayName "Jeeves Runner"`);
|
|
1388
|
+
console.log(` nssm set ${name} Description "Job execution engine with SQLite state"`);
|
|
1389
|
+
console.log(` nssm set ${name} Start SERVICE_AUTO_START`);
|
|
1390
|
+
console.log(` nssm start ${name}`);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
if (process.platform === 'darwin') {
|
|
1394
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1395
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1396
|
+
<plist version="1.0">
|
|
1397
|
+
<dict>
|
|
1398
|
+
<key>Label</key><string>com.jeeves.runner</string>
|
|
1399
|
+
<key>ProgramArguments</key>
|
|
1400
|
+
<array>
|
|
1401
|
+
<string>/usr/local/bin/jeeves-runner</string>
|
|
1402
|
+
<string>start</string>${options.config ? `\n <string>-c</string>\n <string>${options.config}</string>` : ''}
|
|
1403
|
+
</array>
|
|
1404
|
+
<key>RunAtLoad</key><true/>
|
|
1405
|
+
<key>KeepAlive</key><true/>
|
|
1406
|
+
<key>StandardOutPath</key><string>/tmp/${name}.stdout.log</string>
|
|
1407
|
+
<key>StandardErrorPath</key><string>/tmp/${name}.stderr.log</string>
|
|
1408
|
+
</dict>
|
|
1409
|
+
</plist>`;
|
|
1410
|
+
console.log('# launchd plist (macOS)');
|
|
1411
|
+
console.log(`# ~/Library/LaunchAgents/com.jeeves.runner.plist`);
|
|
1412
|
+
console.log(plist);
|
|
1413
|
+
console.log();
|
|
1414
|
+
console.log('# install');
|
|
1415
|
+
console.log(` launchctl load ~/Library/LaunchAgents/com.jeeves.runner.plist`);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
// Linux (systemd)
|
|
1419
|
+
const unit = [
|
|
1420
|
+
'[Unit]',
|
|
1421
|
+
'Description=Jeeves Runner - Job Execution Engine',
|
|
1422
|
+
'After=network.target',
|
|
1423
|
+
'',
|
|
1424
|
+
'[Service]',
|
|
1425
|
+
'Type=simple',
|
|
1426
|
+
'WorkingDirectory=%h',
|
|
1427
|
+
`ExecStart=/usr/bin/env jeeves-runner start${configFlag}`,
|
|
1428
|
+
'Restart=on-failure',
|
|
1429
|
+
'',
|
|
1430
|
+
'[Install]',
|
|
1431
|
+
'WantedBy=default.target',
|
|
1432
|
+
].join('\n');
|
|
1433
|
+
console.log('# systemd unit file (Linux)');
|
|
1434
|
+
console.log(`# ~/.config/systemd/user/${name}.service`);
|
|
1435
|
+
console.log(unit);
|
|
1436
|
+
console.log();
|
|
1437
|
+
console.log('# install');
|
|
1438
|
+
console.log(` systemctl --user daemon-reload`);
|
|
1439
|
+
console.log(` systemctl --user enable --now ${name}.service`);
|
|
1440
|
+
}));
|
|
1441
|
+
service.addCommand(new Command('uninstall')
|
|
1442
|
+
.description('Print uninstall instructions for a system service')
|
|
1443
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-runner')
|
|
1444
|
+
.action((options) => {
|
|
1445
|
+
const { name } = options;
|
|
1446
|
+
if (process.platform === 'win32') {
|
|
1447
|
+
console.log('# NSSM uninstall (Windows)');
|
|
1448
|
+
console.log(` nssm stop ${name}`);
|
|
1449
|
+
console.log(` nssm remove ${name} confirm`);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (process.platform === 'darwin') {
|
|
1453
|
+
console.log('# launchd uninstall (macOS)');
|
|
1454
|
+
console.log(` launchctl unload ~/Library/LaunchAgents/com.jeeves.runner.plist`);
|
|
1455
|
+
console.log(` rm ~/Library/LaunchAgents/com.jeeves.runner.plist`);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
console.log('# systemd uninstall (Linux)');
|
|
1459
|
+
console.log(` systemctl --user disable --now ${name}.service`);
|
|
1460
|
+
console.log(`# rm ~/.config/systemd/user/${name}.service`);
|
|
1461
|
+
console.log(` systemctl --user daemon-reload`);
|
|
1462
|
+
}));
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1246
1465
|
/**
|
|
1247
1466
|
* CLI entry point for jeeves-runner.
|
|
1248
1467
|
*
|
|
@@ -1370,4 +1589,6 @@ program
|
|
|
1370
1589
|
}
|
|
1371
1590
|
})();
|
|
1372
1591
|
});
|
|
1592
|
+
registerConfigCommands(program);
|
|
1593
|
+
registerServiceCommand(program);
|
|
1373
1594
|
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
|
-
|
|
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<{
|
|
@@ -102,8 +102,8 @@ type Queue = z.infer<typeof queueSchema>;
|
|
|
102
102
|
|
|
103
103
|
/** Run status enumeration schema (pending, running, ok, error, timeout, skipped). */
|
|
104
104
|
declare const runStatusSchema: z.ZodEnum<{
|
|
105
|
-
error: "error";
|
|
106
105
|
pending: "pending";
|
|
106
|
+
error: "error";
|
|
107
107
|
running: "running";
|
|
108
108
|
ok: "ok";
|
|
109
109
|
timeout: "timeout";
|
|
@@ -120,8 +120,8 @@ declare const runSchema: z.ZodObject<{
|
|
|
120
120
|
id: z.ZodNumber;
|
|
121
121
|
jobId: z.ZodString;
|
|
122
122
|
status: z.ZodEnum<{
|
|
123
|
-
error: "error";
|
|
124
123
|
pending: "pending";
|
|
124
|
+
error: "error";
|
|
125
125
|
running: "running";
|
|
126
126
|
ok: "ok";
|
|
127
127
|
timeout: "timeout";
|
|
@@ -183,26 +183,18 @@ interface QueueItem {
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
/**
|
|
186
|
-
* Job client library for runner jobs. Provides
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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. */
|
|
@@ -316,6 +316,9 @@ function createConnection(dbPath) {
|
|
|
316
316
|
// Enable WAL mode for better concurrency
|
|
317
317
|
db.exec('PRAGMA journal_mode = WAL;');
|
|
318
318
|
db.exec('PRAGMA foreign_keys = ON;');
|
|
319
|
+
// Wait up to 5s for write lock instead of failing immediately (SQLITE_BUSY).
|
|
320
|
+
// Multiple runner child processes share this DB file concurrently.
|
|
321
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
319
322
|
return db;
|
|
320
323
|
}
|
|
321
324
|
/**
|
|
@@ -326,7 +329,7 @@ function closeConnection(db) {
|
|
|
326
329
|
}
|
|
327
330
|
|
|
328
331
|
/**
|
|
329
|
-
* Database maintenance tasks: run retention pruning and expired
|
|
332
|
+
* Database maintenance tasks: run retention pruning and expired state cleanup.
|
|
330
333
|
*/
|
|
331
334
|
/** Delete runs older than the configured retention period. */
|
|
332
335
|
function pruneOldRuns(db, days, logger) {
|
|
@@ -339,7 +342,7 @@ function pruneOldRuns(db, days, logger) {
|
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
344
|
/** Delete expired state entries. */
|
|
342
|
-
function
|
|
345
|
+
function cleanExpiredState(db, logger) {
|
|
343
346
|
const result = db
|
|
344
347
|
.prepare(`DELETE FROM state WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`)
|
|
345
348
|
.run();
|
|
@@ -369,7 +372,7 @@ function createMaintenance(db, config, logger) {
|
|
|
369
372
|
let interval = null;
|
|
370
373
|
function runAll() {
|
|
371
374
|
pruneOldRuns(db, config.runRetentionDays, logger);
|
|
372
|
-
|
|
375
|
+
cleanExpiredState(db, logger);
|
|
373
376
|
pruneOldQueueItems(db, logger);
|
|
374
377
|
}
|
|
375
378
|
return {
|
|
@@ -377,7 +380,7 @@ function createMaintenance(db, config, logger) {
|
|
|
377
380
|
// Run immediately on startup
|
|
378
381
|
runAll();
|
|
379
382
|
// Then run periodically
|
|
380
|
-
interval = setInterval(runAll, config.
|
|
383
|
+
interval = setInterval(runAll, config.stateCleanupIntervalMs);
|
|
381
384
|
},
|
|
382
385
|
stop() {
|
|
383
386
|
if (interval) {
|
|
@@ -494,6 +497,9 @@ CREATE INDEX idx_queue_items_dedup ON queue_items(queue_id, dedup_key, status);
|
|
|
494
497
|
-- Create new poll index
|
|
495
498
|
CREATE INDEX idx_queue_items_poll ON queue_items(queue_id, status, priority DESC, created_at);
|
|
496
499
|
|
|
500
|
+
-- NOTE: These queue definitions are deployment-specific seeds. New installations
|
|
501
|
+
-- should define queues via the seed script or API, not here. These remain in the
|
|
502
|
+
-- migration for backward compatibility with existing databases.
|
|
497
503
|
-- Seed queue definitions
|
|
498
504
|
INSERT INTO queues (id, name, description, dedup_expr, dedup_scope, max_attempts, retention_days) VALUES
|
|
499
505
|
('email-updates', 'Email Update Queue', NULL, NULL, NULL, 1, 7),
|
|
@@ -641,7 +647,8 @@ function createGatewayClient(options) {
|
|
|
641
647
|
return response.result;
|
|
642
648
|
},
|
|
643
649
|
async getSessionInfo(sessionKey) {
|
|
644
|
-
//
|
|
650
|
+
// TODO: Replace with a direct session lookup when Gateway adds sessions_get.
|
|
651
|
+
// Currently fetches up to 500 sessions and searches client-side � O(n) and
|
|
645
652
|
// and search client-side. Consider using sessions_history with limit 1 as alternative,
|
|
646
653
|
// or request a sessions_get tool from Gateway for more efficient single-session lookup.
|
|
647
654
|
const response = (await invokeGateway(url, token, 'sessions_list', { activeMinutes: 120, limit: 500 }, // Increased from 100 to reduce false negatives
|
|
@@ -687,23 +694,35 @@ function createNotifier(config) {
|
|
|
687
694
|
return {
|
|
688
695
|
async notifySuccess(jobName, durationMs, channel) {
|
|
689
696
|
if (!slackToken) {
|
|
690
|
-
console.warn(`No Slack token configured
|
|
697
|
+
console.warn(`No Slack token configured � skipping success notification for ${jobName}`);
|
|
691
698
|
return;
|
|
692
699
|
}
|
|
693
700
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
694
|
-
const text =
|
|
701
|
+
const text = `?? *${jobName}* completed (${durationSec}s)`;
|
|
695
702
|
await postToSlack(slackToken, channel, text);
|
|
696
703
|
},
|
|
697
704
|
async notifyFailure(jobName, durationMs, error, channel) {
|
|
698
705
|
if (!slackToken) {
|
|
699
|
-
console.warn(`No Slack token configured
|
|
706
|
+
console.warn(`No Slack token configured � skipping failure notification for ${jobName}`);
|
|
700
707
|
return;
|
|
701
708
|
}
|
|
702
709
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
703
710
|
const errorMsg = error ? `: ${error}` : '';
|
|
704
|
-
const text =
|
|
711
|
+
const text = `?? *${jobName}* failed (${durationSec}s)${errorMsg}`;
|
|
705
712
|
await postToSlack(slackToken, channel, text);
|
|
706
713
|
},
|
|
714
|
+
async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
|
|
715
|
+
if (result.status === 'ok' && onSuccess) {
|
|
716
|
+
await this.notifySuccess(jobName, result.durationMs, onSuccess).catch((err) => {
|
|
717
|
+
logger.error({ jobName, err }, 'Success notification failed');
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
else if (result.status !== 'ok' && onFailure) {
|
|
721
|
+
await this.notifyFailure(jobName, result.durationMs, result.error, onFailure).catch((err) => {
|
|
722
|
+
logger.error({ jobName, err }, 'Failure notification failed');
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
},
|
|
707
726
|
};
|
|
708
727
|
}
|
|
709
728
|
|
|
@@ -955,27 +974,6 @@ function createCronRegistry(deps) {
|
|
|
955
974
|
};
|
|
956
975
|
}
|
|
957
976
|
|
|
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
977
|
/**
|
|
980
978
|
* Run record repository for managing job execution records.
|
|
981
979
|
*/
|
|
@@ -1154,7 +1152,7 @@ function createScheduler(deps) {
|
|
|
1154
1152
|
runRepository.finishRun(runId, result);
|
|
1155
1153
|
logger.info({ jobId: id, runId, status: result.status }, 'Job finished');
|
|
1156
1154
|
// Send notifications
|
|
1157
|
-
await
|
|
1155
|
+
await notifier.dispatchResult(result, name, on_success, on_failure, logger);
|
|
1158
1156
|
return result;
|
|
1159
1157
|
}
|
|
1160
1158
|
finally {
|
|
@@ -1284,10 +1282,10 @@ function createRunner(config, deps) {
|
|
|
1284
1282
|
if (gatewayClient) {
|
|
1285
1283
|
logger.info('Gateway client initialized');
|
|
1286
1284
|
}
|
|
1287
|
-
// Maintenance (run retention pruning +
|
|
1285
|
+
// Maintenance (run retention pruning + state cleanup)
|
|
1288
1286
|
maintenance = createMaintenance(db, {
|
|
1289
1287
|
runRetentionDays: config.runRetentionDays,
|
|
1290
|
-
|
|
1288
|
+
stateCleanupIntervalMs: config.stateCleanupIntervalMs,
|
|
1291
1289
|
}, logger);
|
|
1292
1290
|
maintenance.start();
|
|
1293
1291
|
logger.info('Maintenance tasks started');
|
|
@@ -1479,7 +1477,7 @@ function parseTtl(ttl) {
|
|
|
1479
1477
|
/** Create state operations for the given database connection. */
|
|
1480
1478
|
function createStateOps(db) {
|
|
1481
1479
|
return {
|
|
1482
|
-
|
|
1480
|
+
getState(namespace, key) {
|
|
1483
1481
|
const row = db
|
|
1484
1482
|
.prepare(`SELECT value FROM state
|
|
1485
1483
|
WHERE namespace = ? AND key = ?
|
|
@@ -1487,7 +1485,7 @@ function createStateOps(db) {
|
|
|
1487
1485
|
.get(namespace, key);
|
|
1488
1486
|
return row?.value ?? null;
|
|
1489
1487
|
},
|
|
1490
|
-
|
|
1488
|
+
setState(namespace, key, value, options) {
|
|
1491
1489
|
const expiresAt = options?.ttl ? parseTtl(options.ttl) : null;
|
|
1492
1490
|
if (expiresAt) {
|
|
1493
1491
|
db.prepare(`INSERT INTO state (namespace, key, value, expires_at) VALUES (?, ?, ?, ?)
|
|
@@ -1498,17 +1496,8 @@ function createStateOps(db) {
|
|
|
1498
1496
|
ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`).run(namespace, key, value);
|
|
1499
1497
|
}
|
|
1500
1498
|
},
|
|
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
1499
|
deleteState(namespace, key) {
|
|
1511
|
-
|
|
1500
|
+
db.prepare('DELETE FROM state WHERE namespace = ? AND key = ?').run(namespace, key);
|
|
1512
1501
|
},
|
|
1513
1502
|
};
|
|
1514
1503
|
}
|
|
@@ -1562,7 +1551,7 @@ function createCollectionOps(db) {
|
|
|
1562
1551
|
}
|
|
1563
1552
|
|
|
1564
1553
|
/**
|
|
1565
|
-
* Job client library for runner jobs. Provides
|
|
1554
|
+
* Job client library for runner jobs. Provides state and queue operations. Opens its own DB connection via JR_DB_PATH env var.
|
|
1566
1555
|
*/
|
|
1567
1556
|
/**
|
|
1568
1557
|
* 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.1",
|
|
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
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
"
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
[](https://www.npmjs.com/package/@karmaniverous/jeeves-runner)
|
|
4
|
-

|
|
5
|
-
[](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
|