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