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