@karmaniverous/jeeves-runner 0.2.0 → 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.
- package/dist/cli/jeeves-runner/index.js +255 -37
- package/dist/index.d.ts +21 -18
- package/dist/mjs/index.js +35 -49
- 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';
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 +
|
|
1123
|
+
// Maintenance (run retention pruning + state cleanup)
|
|
1129
1124
|
maintenance = createMaintenance(db, {
|
|
1130
1125
|
runRetentionDays: config.runRetentionDays,
|
|
1131
|
-
|
|
1126
|
+
stateCleanupIntervalMs: config.stateCleanupIntervalMs,
|
|
1132
1127
|
}, logger);
|
|
1133
1128
|
maintenance.start();
|
|
1134
1129
|
logger.info('Maintenance tasks started');
|
|
@@ -1219,15 +1214,15 @@ const gatewaySchema = z.object({
|
|
|
1219
1214
|
/** Full runner configuration schema. Validates and provides defaults. */
|
|
1220
1215
|
const runnerConfigSchema = z.object({
|
|
1221
1216
|
/** HTTP server port for the runner API. */
|
|
1222
|
-
port: z.number().default(
|
|
1217
|
+
port: z.number().default(1937),
|
|
1223
1218
|
/** Path to SQLite database file. */
|
|
1224
1219
|
dbPath: z.string().default('./data/runner.sqlite'),
|
|
1225
1220
|
/** Maximum number of concurrent job executions. */
|
|
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
|
|
1230
|
-
|
|
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
|
-
|
|
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
|
-
pending: "pending";
|
|
106
105
|
error: "error";
|
|
106
|
+
pending: "pending";
|
|
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
|
-
pending: "pending";
|
|
124
123
|
error: "error";
|
|
124
|
+
pending: "pending";
|
|
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
|
@@ -43,15 +43,15 @@ const gatewaySchema = z.object({
|
|
|
43
43
|
/** Full runner configuration schema. Validates and provides defaults. */
|
|
44
44
|
const runnerConfigSchema = z.object({
|
|
45
45
|
/** HTTP server port for the runner API. */
|
|
46
|
-
port: z.number().default(
|
|
46
|
+
port: z.number().default(1937),
|
|
47
47
|
/** Path to SQLite database file. */
|
|
48
48
|
dbPath: z.string().default('./data/runner.sqlite'),
|
|
49
49
|
/** Maximum number of concurrent job executions. */
|
|
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. */
|
|
@@ -326,7 +326,7 @@ function closeConnection(db) {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
/**
|
|
329
|
-
* Database maintenance tasks: run retention pruning and expired
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 +
|
|
1282
|
+
// Maintenance (run retention pruning + state cleanup)
|
|
1288
1283
|
maintenance = createMaintenance(db, {
|
|
1289
1284
|
runRetentionDays: config.runRetentionDays,
|
|
1290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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.0"
|
|
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
|