@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
package/dist/mjs/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, extname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { pino } from 'pino';
|
|
4
6
|
import Fastify from 'fastify';
|
|
5
7
|
import { createConfigQueryHandler } from '@karmaniverous/jeeves';
|
|
6
|
-
import {
|
|
8
|
+
import { RRStack } from '@karmaniverous/rrstack';
|
|
9
|
+
import { Cron } from 'croner';
|
|
10
|
+
import { JSONPath } from 'jsonpath-plus';
|
|
7
11
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
12
|
import { request as request$1 } from 'node:http';
|
|
9
13
|
import { request } from 'node:https';
|
|
10
14
|
import { spawn } from 'node:child_process';
|
|
11
|
-
import {
|
|
12
|
-
import { JSONPath } from 'jsonpath-plus';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Runner configuration schema and types.
|
|
@@ -45,6 +48,8 @@ const gatewaySchema = z.object({
|
|
|
45
48
|
const runnerConfigSchema = z.object({
|
|
46
49
|
/** HTTP server port for the runner API. */
|
|
47
50
|
port: z.number().default(1937),
|
|
51
|
+
/** Bind address for the HTTP server. */
|
|
52
|
+
host: z.string().default('127.0.0.1'),
|
|
48
53
|
/** Path to SQLite database file. */
|
|
49
54
|
dbPath: z.string().default('./data/runner.sqlite'),
|
|
50
55
|
/** Maximum number of concurrent job executions. */
|
|
@@ -91,8 +96,8 @@ const jobSchema = z.object({
|
|
|
91
96
|
enabled: z.boolean().default(true),
|
|
92
97
|
/** Optional execution timeout in milliseconds. */
|
|
93
98
|
timeoutMs: z.number().optional(),
|
|
94
|
-
/** Policy for handling overlapping job executions (skip
|
|
95
|
-
overlapPolicy: z.enum(['skip', '
|
|
99
|
+
/** Policy for handling overlapping job executions (skip or allow). */
|
|
100
|
+
overlapPolicy: z.enum(['skip', 'allow']).default('skip'),
|
|
96
101
|
/** Slack channel ID for failure notifications. */
|
|
97
102
|
onFailure: z.string().nullable().default(null),
|
|
98
103
|
/** Slack channel ID for success notifications. */
|
|
@@ -170,6 +175,385 @@ const runSchema = z.object({
|
|
|
170
175
|
trigger: runTriggerSchema.default('schedule'),
|
|
171
176
|
});
|
|
172
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Schedule parsing, validation, and next-fire-time computation for cron and RRStack formats.
|
|
180
|
+
*
|
|
181
|
+
* @module
|
|
182
|
+
*/
|
|
183
|
+
/**
|
|
184
|
+
* Attempt to parse a schedule string as RRStack JSON (non-null, non-array
|
|
185
|
+
* object). Returns the parsed options on success, or null if the string
|
|
186
|
+
* is not a JSON object.
|
|
187
|
+
*/
|
|
188
|
+
function tryParseRRStack(schedule) {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(schedule);
|
|
191
|
+
if (typeof parsed === 'object' &&
|
|
192
|
+
parsed !== null &&
|
|
193
|
+
!Array.isArray(parsed)) {
|
|
194
|
+
return parsed;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Compute the next fire time for a schedule string.
|
|
204
|
+
* Supports cron expressions (via croner) and RRStack JSON (via nextEvent).
|
|
205
|
+
*
|
|
206
|
+
* @param schedule - Cron expression or RRStack JSON string.
|
|
207
|
+
* @returns Next fire time as a Date, or null if none can be determined.
|
|
208
|
+
*/
|
|
209
|
+
function getNextFireTime(schedule) {
|
|
210
|
+
const rrOpts = tryParseRRStack(schedule);
|
|
211
|
+
if (rrOpts) {
|
|
212
|
+
const stack = new RRStack(rrOpts);
|
|
213
|
+
const next = stack.nextEvent();
|
|
214
|
+
if (!next)
|
|
215
|
+
return null;
|
|
216
|
+
const unit = stack.timeUnit;
|
|
217
|
+
return new Date(unit === 's' ? next.at * 1000 : next.at);
|
|
218
|
+
}
|
|
219
|
+
const cron = new Cron(schedule);
|
|
220
|
+
return cron.nextRun() ?? null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Validate a schedule string as either a cron expression or RRStack JSON.
|
|
224
|
+
*
|
|
225
|
+
* @param schedule - Cron expression or RRStack JSON string.
|
|
226
|
+
* @returns Validation result with format on success, or error message on failure.
|
|
227
|
+
*/
|
|
228
|
+
function validateSchedule(schedule) {
|
|
229
|
+
const rrOpts = tryParseRRStack(schedule);
|
|
230
|
+
if (rrOpts) {
|
|
231
|
+
try {
|
|
232
|
+
new RRStack(rrOpts);
|
|
233
|
+
return { valid: true, format: 'rrstack' };
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
return {
|
|
237
|
+
valid: false,
|
|
238
|
+
error: `Invalid RRStack schedule: ${err instanceof Error ? err.message : String(err)}`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
new Cron(schedule);
|
|
244
|
+
return { valid: true, format: 'cron' };
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
error: `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Job CRUD routes: create, update, delete, enable/disable, and script update endpoints.
|
|
256
|
+
*
|
|
257
|
+
* @module
|
|
258
|
+
*/
|
|
259
|
+
/** Zod schema for job creation/update request body. */
|
|
260
|
+
const createJobSchema = z.object({
|
|
261
|
+
id: z.string().min(1),
|
|
262
|
+
name: z.string().min(1),
|
|
263
|
+
schedule: z.string().min(1),
|
|
264
|
+
script: z.string().min(1),
|
|
265
|
+
source_type: z.enum(['path', 'inline']).default('path'),
|
|
266
|
+
type: z.enum(['script', 'session']).default('script'),
|
|
267
|
+
timeout_seconds: z.number().positive().optional(),
|
|
268
|
+
overlap_policy: z.enum(['skip', 'allow']).default('skip'),
|
|
269
|
+
enabled: z.boolean().default(true),
|
|
270
|
+
description: z.string().optional(),
|
|
271
|
+
on_failure: z.string().nullable().optional(),
|
|
272
|
+
on_success: z.string().nullable().optional(),
|
|
273
|
+
});
|
|
274
|
+
/** Zod schema for job update (all fields optional except what's set). */
|
|
275
|
+
const updateJobSchema = createJobSchema.omit({ id: true }).partial();
|
|
276
|
+
/** Zod schema for script update request body. */
|
|
277
|
+
const updateScriptSchema = z.object({
|
|
278
|
+
script: z.string().min(1),
|
|
279
|
+
source_type: z.enum(['path', 'inline']).optional(),
|
|
280
|
+
});
|
|
281
|
+
/** Standard error message for missing job resources. */
|
|
282
|
+
const JOB_NOT_FOUND = 'Job not found';
|
|
283
|
+
/**
|
|
284
|
+
* Register job CRUD routes on the Fastify instance.
|
|
285
|
+
*/
|
|
286
|
+
function registerJobRoutes(app, deps) {
|
|
287
|
+
const { db, scheduler } = deps;
|
|
288
|
+
/** POST /jobs — Create a new job. */
|
|
289
|
+
app.post('/jobs', (request, reply) => {
|
|
290
|
+
const parsed = createJobSchema.safeParse(request.body);
|
|
291
|
+
if (!parsed.success) {
|
|
292
|
+
reply.code(400);
|
|
293
|
+
return { error: parsed.error.message };
|
|
294
|
+
}
|
|
295
|
+
const data = parsed.data;
|
|
296
|
+
const validation = validateSchedule(data.schedule);
|
|
297
|
+
if (!validation.valid) {
|
|
298
|
+
reply.code(400);
|
|
299
|
+
return { error: validation.error };
|
|
300
|
+
}
|
|
301
|
+
const timeoutMs = data.timeout_seconds ? data.timeout_seconds * 1000 : null;
|
|
302
|
+
db.prepare(`INSERT INTO jobs (id, name, schedule, script, source_type, type, timeout_ms,
|
|
303
|
+
overlap_policy, enabled, description, on_failure, on_success)
|
|
304
|
+
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);
|
|
305
|
+
scheduler.reconcileNow();
|
|
306
|
+
reply.code(201);
|
|
307
|
+
return { ok: true, id: data.id };
|
|
308
|
+
});
|
|
309
|
+
/** PATCH /jobs/:id — Partial update of an existing job. */
|
|
310
|
+
app.patch('/jobs/:id', (request, reply) => {
|
|
311
|
+
const parsed = updateJobSchema.safeParse(request.body);
|
|
312
|
+
if (!parsed.success) {
|
|
313
|
+
reply.code(400);
|
|
314
|
+
return { error: parsed.error.message };
|
|
315
|
+
}
|
|
316
|
+
const existing = db
|
|
317
|
+
.prepare('SELECT id FROM jobs WHERE id = ?')
|
|
318
|
+
.get(request.params.id);
|
|
319
|
+
if (!existing) {
|
|
320
|
+
reply.code(404);
|
|
321
|
+
return { error: JOB_NOT_FOUND };
|
|
322
|
+
}
|
|
323
|
+
const data = parsed.data;
|
|
324
|
+
if (data.schedule) {
|
|
325
|
+
const validation = validateSchedule(data.schedule);
|
|
326
|
+
if (!validation.valid) {
|
|
327
|
+
reply.code(400);
|
|
328
|
+
return { error: validation.error };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Map input field → DB column + value transform. */
|
|
332
|
+
const fieldMap = [
|
|
333
|
+
{ input: 'name', column: 'name' },
|
|
334
|
+
{ input: 'schedule', column: 'schedule' },
|
|
335
|
+
{ input: 'script', column: 'script' },
|
|
336
|
+
{ input: 'source_type', column: 'source_type' },
|
|
337
|
+
{ input: 'type', column: 'type' },
|
|
338
|
+
{
|
|
339
|
+
input: 'timeout_seconds',
|
|
340
|
+
column: 'timeout_ms',
|
|
341
|
+
transform: (v) => v * 1000,
|
|
342
|
+
},
|
|
343
|
+
{ input: 'overlap_policy', column: 'overlap_policy' },
|
|
344
|
+
{
|
|
345
|
+
input: 'enabled',
|
|
346
|
+
column: 'enabled',
|
|
347
|
+
transform: (v) => (v ? 1 : 0),
|
|
348
|
+
},
|
|
349
|
+
{ input: 'description', column: 'description' },
|
|
350
|
+
{ input: 'on_failure', column: 'on_failure' },
|
|
351
|
+
{ input: 'on_success', column: 'on_success' },
|
|
352
|
+
];
|
|
353
|
+
const sets = [];
|
|
354
|
+
const values = [];
|
|
355
|
+
for (const { input, column, transform } of fieldMap) {
|
|
356
|
+
if (data[input] !== undefined) {
|
|
357
|
+
sets.push(`${column} = ?`);
|
|
358
|
+
values.push(transform ? transform(data[input]) : data[input]);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (sets.length > 0) {
|
|
362
|
+
sets.push("updated_at = datetime('now')");
|
|
363
|
+
values.push(request.params.id);
|
|
364
|
+
db.prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
|
365
|
+
}
|
|
366
|
+
scheduler.reconcileNow();
|
|
367
|
+
return { ok: true };
|
|
368
|
+
});
|
|
369
|
+
/** DELETE /jobs/:id — Delete a job and its runs. */
|
|
370
|
+
app.delete('/jobs/:id', (request, reply) => {
|
|
371
|
+
db.prepare('DELETE FROM runs WHERE job_id = ?').run(request.params.id);
|
|
372
|
+
const result = db
|
|
373
|
+
.prepare('DELETE FROM jobs WHERE id = ?')
|
|
374
|
+
.run(request.params.id);
|
|
375
|
+
if (result.changes === 0) {
|
|
376
|
+
reply.code(404);
|
|
377
|
+
return { error: JOB_NOT_FOUND };
|
|
378
|
+
}
|
|
379
|
+
scheduler.reconcileNow();
|
|
380
|
+
return { ok: true };
|
|
381
|
+
});
|
|
382
|
+
/** Register a PATCH toggle endpoint (enable or disable). */
|
|
383
|
+
function registerToggle(path, enabledValue) {
|
|
384
|
+
app.patch(path, (request, reply) => {
|
|
385
|
+
const result = db
|
|
386
|
+
.prepare('UPDATE jobs SET enabled = ? WHERE id = ?')
|
|
387
|
+
.run(enabledValue, request.params.id);
|
|
388
|
+
if (result.changes === 0) {
|
|
389
|
+
reply.code(404);
|
|
390
|
+
return { error: JOB_NOT_FOUND };
|
|
391
|
+
}
|
|
392
|
+
scheduler.reconcileNow();
|
|
393
|
+
return { ok: true };
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
registerToggle('/jobs/:id/enable', 1);
|
|
397
|
+
registerToggle('/jobs/:id/disable', 0);
|
|
398
|
+
/** PUT /jobs/:id/script — Update job script and source_type. */
|
|
399
|
+
app.put('/jobs/:id/script', (request, reply) => {
|
|
400
|
+
const parsed = updateScriptSchema.safeParse(request.body);
|
|
401
|
+
if (!parsed.success) {
|
|
402
|
+
reply.code(400);
|
|
403
|
+
return { error: parsed.error.message };
|
|
404
|
+
}
|
|
405
|
+
const data = parsed.data;
|
|
406
|
+
const result = data.source_type
|
|
407
|
+
? db
|
|
408
|
+
.prepare('UPDATE jobs SET script = ?, source_type = ? WHERE id = ?')
|
|
409
|
+
.run(data.script, data.source_type, request.params.id)
|
|
410
|
+
: db
|
|
411
|
+
.prepare('UPDATE jobs SET script = ? WHERE id = ?')
|
|
412
|
+
.run(data.script, request.params.id);
|
|
413
|
+
if (result.changes === 0) {
|
|
414
|
+
reply.code(404);
|
|
415
|
+
return { error: JOB_NOT_FOUND };
|
|
416
|
+
}
|
|
417
|
+
scheduler.reconcileNow();
|
|
418
|
+
return { ok: true };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Queue inspection routes: list queues, queue status, and peek at pending items.
|
|
424
|
+
*
|
|
425
|
+
* @module
|
|
426
|
+
*/
|
|
427
|
+
/**
|
|
428
|
+
* Register queue inspection routes on the Fastify instance.
|
|
429
|
+
*/
|
|
430
|
+
function registerQueueRoutes(app, deps) {
|
|
431
|
+
const { db } = deps;
|
|
432
|
+
/** GET /queues — List all distinct queues that have items. */
|
|
433
|
+
app.get('/queues', () => {
|
|
434
|
+
const rows = db
|
|
435
|
+
.prepare('SELECT DISTINCT queue_id FROM queue_items')
|
|
436
|
+
.all();
|
|
437
|
+
return { queues: rows.map((r) => r.queue_id) };
|
|
438
|
+
});
|
|
439
|
+
/** GET /queues/:name/status — Queue depth, claimed count, failed count, oldest age. */
|
|
440
|
+
app.get('/queues/:name/status', (request) => {
|
|
441
|
+
const row = db
|
|
442
|
+
.prepare(`SELECT
|
|
443
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS depth,
|
|
444
|
+
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) AS claimed,
|
|
445
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
|
|
446
|
+
MIN(CASE WHEN status = 'pending' THEN created_at END) AS oldest
|
|
447
|
+
FROM queue_items
|
|
448
|
+
WHERE queue_id = ?`)
|
|
449
|
+
.get(request.params.name);
|
|
450
|
+
return {
|
|
451
|
+
depth: row.depth ?? 0,
|
|
452
|
+
claimedCount: row.claimed ?? 0,
|
|
453
|
+
failedCount: row.failed ?? 0,
|
|
454
|
+
oldestAge: row.oldest
|
|
455
|
+
? Date.now() - new Date(row.oldest).getTime()
|
|
456
|
+
: null,
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
/** GET /queues/:name/peek — Non-claiming read of pending items. */
|
|
460
|
+
app.get('/queues/:name/peek', (request) => {
|
|
461
|
+
const name = request.params.name;
|
|
462
|
+
const limit = parseInt(request.query.limit ?? '10', 10);
|
|
463
|
+
const items = db
|
|
464
|
+
.prepare(`SELECT id, payload, priority, created_at FROM queue_items
|
|
465
|
+
WHERE queue_id = ? AND status = 'pending'
|
|
466
|
+
ORDER BY priority DESC, created_at
|
|
467
|
+
LIMIT ?`)
|
|
468
|
+
.all(name, limit);
|
|
469
|
+
return {
|
|
470
|
+
items: items.map((item) => ({
|
|
471
|
+
id: item.id,
|
|
472
|
+
payload: JSON.parse(item.payload),
|
|
473
|
+
priority: item.priority,
|
|
474
|
+
createdAt: item.created_at,
|
|
475
|
+
})),
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* State inspection routes: list namespaces, read scalar state, and read collection items.
|
|
482
|
+
*
|
|
483
|
+
* @module
|
|
484
|
+
*/
|
|
485
|
+
/**
|
|
486
|
+
* Register state inspection routes on the Fastify instance.
|
|
487
|
+
*/
|
|
488
|
+
function registerStateRoutes(app, deps) {
|
|
489
|
+
const { db } = deps;
|
|
490
|
+
/** GET /state — List all distinct namespaces. */
|
|
491
|
+
app.get('/state', () => {
|
|
492
|
+
const rows = db
|
|
493
|
+
.prepare('SELECT DISTINCT namespace FROM state')
|
|
494
|
+
.all();
|
|
495
|
+
return { namespaces: rows.map((r) => r.namespace) };
|
|
496
|
+
});
|
|
497
|
+
/** GET /state/:namespace — Materialise all scalar state as key-value map. */
|
|
498
|
+
app.get('/state/:namespace', (request) => {
|
|
499
|
+
const { namespace } = request.params;
|
|
500
|
+
const rows = db
|
|
501
|
+
.prepare(`SELECT key, value FROM state
|
|
502
|
+
WHERE namespace = ?
|
|
503
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))`)
|
|
504
|
+
.all(namespace);
|
|
505
|
+
const state = {};
|
|
506
|
+
for (const row of rows) {
|
|
507
|
+
state[row.key] = row.value;
|
|
508
|
+
}
|
|
509
|
+
if (request.query.path) {
|
|
510
|
+
const result = JSONPath({
|
|
511
|
+
path: request.query.path,
|
|
512
|
+
json: state,
|
|
513
|
+
});
|
|
514
|
+
return { result, count: Array.isArray(result) ? result.length : 1 };
|
|
515
|
+
}
|
|
516
|
+
return state;
|
|
517
|
+
});
|
|
518
|
+
/** GET /state/:namespace/:key — Read collection items for a state key. */
|
|
519
|
+
app.get('/state/:namespace/:key', (request) => {
|
|
520
|
+
const { namespace, key } = request.params;
|
|
521
|
+
const limit = parseInt(request.query.limit ?? '100', 10);
|
|
522
|
+
const order = request.query.order === 'asc' ? 'ASC' : 'DESC';
|
|
523
|
+
// First check scalar state value
|
|
524
|
+
const scalar = db
|
|
525
|
+
.prepare(`SELECT value FROM state
|
|
526
|
+
WHERE namespace = ? AND key = ?
|
|
527
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))`)
|
|
528
|
+
.get(namespace, key);
|
|
529
|
+
// Get collection items
|
|
530
|
+
const items = db
|
|
531
|
+
.prepare(`SELECT item_key, value, updated_at FROM state_items
|
|
532
|
+
WHERE namespace = ? AND key = ?
|
|
533
|
+
ORDER BY updated_at ${order}
|
|
534
|
+
LIMIT ?`)
|
|
535
|
+
.all(namespace, key, limit);
|
|
536
|
+
const mappedItems = items.map((item) => ({
|
|
537
|
+
itemKey: item.item_key,
|
|
538
|
+
value: item.value,
|
|
539
|
+
updatedAt: item.updated_at,
|
|
540
|
+
}));
|
|
541
|
+
const body = {
|
|
542
|
+
value: scalar?.value ?? null,
|
|
543
|
+
items: mappedItems,
|
|
544
|
+
count: mappedItems.length,
|
|
545
|
+
};
|
|
546
|
+
if (request.query.path) {
|
|
547
|
+
const result = JSONPath({
|
|
548
|
+
path: request.query.path,
|
|
549
|
+
json: body,
|
|
550
|
+
});
|
|
551
|
+
return { result, count: Array.isArray(result) ? result.length : 1 };
|
|
552
|
+
}
|
|
553
|
+
return body;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
173
557
|
/**
|
|
174
558
|
* Fastify API routes for job management and monitoring. Provides endpoints for job CRUD, run history, manual triggers, system stats, and config queries.
|
|
175
559
|
*
|
|
@@ -179,13 +563,31 @@ const runSchema = z.object({
|
|
|
179
563
|
* Register all API routes on the Fastify instance.
|
|
180
564
|
*/
|
|
181
565
|
function registerRoutes(app, deps) {
|
|
182
|
-
const { db, scheduler, getConfig } = deps;
|
|
183
|
-
/** GET /
|
|
184
|
-
app.get('/
|
|
566
|
+
const { db, scheduler, getConfig, version } = deps;
|
|
567
|
+
/** GET /status — Unified status endpoint (single health/metadata entrypoint). */
|
|
568
|
+
app.get('/status', () => {
|
|
569
|
+
const totalJobs = db
|
|
570
|
+
.prepare('SELECT COUNT(*) as count FROM jobs')
|
|
571
|
+
.get();
|
|
572
|
+
const runningCount = scheduler.getRunningJobs().length;
|
|
573
|
+
const failedCount = scheduler.getFailedRegistrations().length;
|
|
574
|
+
const okLastHour = db
|
|
575
|
+
.prepare(`SELECT COUNT(*) as count FROM runs
|
|
576
|
+
WHERE status = 'ok' AND started_at > datetime('now', '-1 hour')`)
|
|
577
|
+
.get();
|
|
578
|
+
const errorsLastHour = db
|
|
579
|
+
.prepare(`SELECT COUNT(*) as count FROM runs
|
|
580
|
+
WHERE status IN ('error', 'timeout') AND started_at > datetime('now', '-1 hour')`)
|
|
581
|
+
.get();
|
|
185
582
|
return {
|
|
186
|
-
|
|
583
|
+
status: 'ok',
|
|
584
|
+
version,
|
|
187
585
|
uptime: process.uptime(),
|
|
188
|
-
|
|
586
|
+
totalJobs: totalJobs.count,
|
|
587
|
+
running: runningCount,
|
|
588
|
+
failedRegistrations: failedCount,
|
|
589
|
+
okLastHour: okLastHour.count,
|
|
590
|
+
errorsLastHour: errorsLastHour.count,
|
|
189
591
|
};
|
|
190
592
|
});
|
|
191
593
|
/** GET /jobs — List all jobs with last run status. */
|
|
@@ -199,7 +601,7 @@ function registerRoutes(app, deps) {
|
|
|
199
601
|
return { jobs: rows };
|
|
200
602
|
});
|
|
201
603
|
/** GET /jobs/:id — Single job detail. */
|
|
202
|
-
app.get('/jobs/:id',
|
|
604
|
+
app.get('/jobs/:id', (request, reply) => {
|
|
203
605
|
const job = db
|
|
204
606
|
.prepare('SELECT * FROM jobs WHERE id = ?')
|
|
205
607
|
.get(request.params.id);
|
|
@@ -228,53 +630,6 @@ function registerRoutes(app, deps) {
|
|
|
228
630
|
return { error: err instanceof Error ? err.message : 'Unknown error' };
|
|
229
631
|
}
|
|
230
632
|
});
|
|
231
|
-
/** POST /jobs/:id/enable — Enable a job. */
|
|
232
|
-
app.post('/jobs/:id/enable', (request, reply) => {
|
|
233
|
-
const result = db
|
|
234
|
-
.prepare('UPDATE jobs SET enabled = 1 WHERE id = ?')
|
|
235
|
-
.run(request.params.id);
|
|
236
|
-
if (result.changes === 0) {
|
|
237
|
-
reply.code(404);
|
|
238
|
-
return { error: 'Job not found' };
|
|
239
|
-
}
|
|
240
|
-
scheduler.reconcileNow();
|
|
241
|
-
return { ok: true };
|
|
242
|
-
});
|
|
243
|
-
/** POST /jobs/:id/disable — Disable a job. */
|
|
244
|
-
app.post('/jobs/:id/disable', (request, reply) => {
|
|
245
|
-
const result = db
|
|
246
|
-
.prepare('UPDATE jobs SET enabled = 0 WHERE id = ?')
|
|
247
|
-
.run(request.params.id);
|
|
248
|
-
if (result.changes === 0) {
|
|
249
|
-
reply.code(404);
|
|
250
|
-
return { error: 'Job not found' };
|
|
251
|
-
}
|
|
252
|
-
scheduler.reconcileNow();
|
|
253
|
-
return { ok: true };
|
|
254
|
-
});
|
|
255
|
-
/** GET /stats — Aggregate job statistics. */
|
|
256
|
-
app.get('/stats', () => {
|
|
257
|
-
const totalJobs = db
|
|
258
|
-
.prepare('SELECT COUNT(*) as count FROM jobs')
|
|
259
|
-
.get();
|
|
260
|
-
const runningCount = scheduler.getRunningJobs().length;
|
|
261
|
-
const failedCount = scheduler.getFailedRegistrations().length;
|
|
262
|
-
const okLastHour = db
|
|
263
|
-
.prepare(`SELECT COUNT(*) as count FROM runs
|
|
264
|
-
WHERE status = 'ok' AND started_at > datetime('now', '-1 hour')`)
|
|
265
|
-
.get();
|
|
266
|
-
const errorsLastHour = db
|
|
267
|
-
.prepare(`SELECT COUNT(*) as count FROM runs
|
|
268
|
-
WHERE status IN ('error', 'timeout') AND started_at > datetime('now', '-1 hour')`)
|
|
269
|
-
.get();
|
|
270
|
-
return {
|
|
271
|
-
totalJobs: totalJobs.count,
|
|
272
|
-
running: runningCount,
|
|
273
|
-
failedRegistrations: failedCount,
|
|
274
|
-
okLastHour: okLastHour.count,
|
|
275
|
-
errorsLastHour: errorsLastHour.count,
|
|
276
|
-
};
|
|
277
|
-
});
|
|
278
633
|
/** GET /config — Query effective configuration via JSONPath. */
|
|
279
634
|
const configHandler = createConfigQueryHandler(getConfig);
|
|
280
635
|
app.get('/config', async (request, reply) => {
|
|
@@ -283,10 +638,17 @@ function registerRoutes(app, deps) {
|
|
|
283
638
|
});
|
|
284
639
|
return reply.status(result.status).send(result.body);
|
|
285
640
|
});
|
|
641
|
+
// Register job CRUD routes (POST, PATCH, DELETE, PATCH enable/disable, PUT script)
|
|
642
|
+
registerJobRoutes(app, { db, scheduler });
|
|
643
|
+
// Register queue and state inspection routes
|
|
644
|
+
registerQueueRoutes(app, { db });
|
|
645
|
+
registerStateRoutes(app, { db });
|
|
286
646
|
}
|
|
287
647
|
|
|
288
648
|
/**
|
|
289
649
|
* Fastify HTTP server for runner API. Creates server instance with logging, registers routes, listens on configured port (localhost only).
|
|
650
|
+
*
|
|
651
|
+
* @module
|
|
290
652
|
*/
|
|
291
653
|
/**
|
|
292
654
|
* Create and configure the Fastify server. Routes are registered but server is not started.
|
|
@@ -311,12 +673,15 @@ function createServer(deps) {
|
|
|
311
673
|
db: deps.db,
|
|
312
674
|
scheduler: deps.scheduler,
|
|
313
675
|
getConfig: deps.getConfig,
|
|
676
|
+
version: deps.version,
|
|
314
677
|
});
|
|
315
678
|
return app;
|
|
316
679
|
}
|
|
317
680
|
|
|
318
681
|
/**
|
|
319
682
|
* SQLite connection manager. Creates DB file with parent directories, enables WAL mode for concurrency.
|
|
683
|
+
*
|
|
684
|
+
* @module
|
|
320
685
|
*/
|
|
321
686
|
/**
|
|
322
687
|
* Create and configure a SQLite database connection.
|
|
@@ -345,6 +710,8 @@ function closeConnection(db) {
|
|
|
345
710
|
|
|
346
711
|
/**
|
|
347
712
|
* Database maintenance tasks: run retention pruning and expired state cleanup.
|
|
713
|
+
*
|
|
714
|
+
* @module
|
|
348
715
|
*/
|
|
349
716
|
/** Delete runs older than the configured retention period. */
|
|
350
717
|
function pruneOldRuns(db, days, logger) {
|
|
@@ -411,6 +778,8 @@ function createMaintenance(db, config, logger) {
|
|
|
411
778
|
|
|
412
779
|
/**
|
|
413
780
|
* Schema migration runner. Tracks applied migrations via schema_version table, applies pending migrations idempotently.
|
|
781
|
+
*
|
|
782
|
+
* @module
|
|
414
783
|
*/
|
|
415
784
|
/** Initial schema migration SQL (embedded to avoid runtime file resolution issues). */
|
|
416
785
|
const MIGRATION_001 = `
|
|
@@ -544,11 +913,16 @@ CREATE TABLE state_items (
|
|
|
544
913
|
);
|
|
545
914
|
CREATE INDEX idx_state_items_ns_key ON state_items(namespace, key);
|
|
546
915
|
`;
|
|
916
|
+
/** Migration 004: Add source_type column to jobs. */
|
|
917
|
+
const MIGRATION_004 = `
|
|
918
|
+
ALTER TABLE jobs ADD COLUMN source_type TEXT DEFAULT 'path';
|
|
919
|
+
`;
|
|
547
920
|
/** Registry of all migrations keyed by version number. */
|
|
548
921
|
const MIGRATIONS = {
|
|
549
922
|
1: MIGRATION_001,
|
|
550
923
|
2: MIGRATION_002,
|
|
551
924
|
3: MIGRATION_003,
|
|
925
|
+
4: MIGRATION_004,
|
|
552
926
|
};
|
|
553
927
|
/**
|
|
554
928
|
* Run all pending migrations. Creates schema_version table if needed, applies migrations in order.
|
|
@@ -579,6 +953,8 @@ function runMigrations(db) {
|
|
|
579
953
|
|
|
580
954
|
/**
|
|
581
955
|
* Shared HTTP utility for making POST requests.
|
|
956
|
+
*
|
|
957
|
+
* @module
|
|
582
958
|
*/
|
|
583
959
|
/** Make an HTTP/HTTPS POST request. Returns the parsed JSON response body. */
|
|
584
960
|
function httpPost(url, headers, body, timeoutMs = 30000) {
|
|
@@ -626,6 +1002,8 @@ function httpPost(url, headers, body, timeoutMs = 30000) {
|
|
|
626
1002
|
|
|
627
1003
|
/**
|
|
628
1004
|
* OpenClaw Gateway HTTP client for spawning and monitoring sessions.
|
|
1005
|
+
*
|
|
1006
|
+
* @module
|
|
629
1007
|
*/
|
|
630
1008
|
/** Make an HTTP POST request to the Gateway /tools/invoke endpoint. */
|
|
631
1009
|
function invokeGateway(url, token, tool, args, timeoutMs = 30000) {
|
|
@@ -692,6 +1070,8 @@ function createGatewayClient(options) {
|
|
|
692
1070
|
|
|
693
1071
|
/**
|
|
694
1072
|
* Slack notification module. Sends job completion/failure messages via Slack Web API (chat.postMessage). Falls back gracefully if no token.
|
|
1073
|
+
*
|
|
1074
|
+
* @module
|
|
695
1075
|
*/
|
|
696
1076
|
/** Post a message to Slack via chat.postMessage API. */
|
|
697
1077
|
async function postToSlack(token, channel, text) {
|
|
@@ -709,21 +1089,21 @@ function createNotifier(config) {
|
|
|
709
1089
|
return {
|
|
710
1090
|
async notifySuccess(jobName, durationMs, channel) {
|
|
711
1091
|
if (!slackToken) {
|
|
712
|
-
console.warn(`No Slack token configured
|
|
1092
|
+
console.warn(`No Slack token configured \u2014 skipping success notification for ${jobName}`);
|
|
713
1093
|
return;
|
|
714
1094
|
}
|
|
715
1095
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
716
|
-
const text =
|
|
1096
|
+
const text = `\u2705 *${jobName}* completed (${durationSec}s)`;
|
|
717
1097
|
await postToSlack(slackToken, channel, text);
|
|
718
1098
|
},
|
|
719
1099
|
async notifyFailure(jobName, durationMs, error, channel) {
|
|
720
1100
|
if (!slackToken) {
|
|
721
|
-
console.warn(`No Slack token configured
|
|
1101
|
+
console.warn(`No Slack token configured \u2014 skipping failure notification for ${jobName}`);
|
|
722
1102
|
return;
|
|
723
1103
|
}
|
|
724
1104
|
const durationSec = (durationMs / 1000).toFixed(1);
|
|
725
1105
|
const errorMsg = error ? `: ${error}` : '';
|
|
726
|
-
const text =
|
|
1106
|
+
const text = `\u274c *${jobName}* failed (${durationSec}s)${errorMsg}`;
|
|
727
1107
|
await postToSlack(slackToken, channel, text);
|
|
728
1108
|
},
|
|
729
1109
|
async dispatchResult(result, jobName, onSuccess, onFailure, logger) {
|
|
@@ -743,6 +1123,8 @@ function createNotifier(config) {
|
|
|
743
1123
|
|
|
744
1124
|
/**
|
|
745
1125
|
* Job executor. Spawns job scripts as child processes, captures output, parses result metadata, enforces timeouts.
|
|
1126
|
+
*
|
|
1127
|
+
* @module
|
|
746
1128
|
*/
|
|
747
1129
|
/** Ring buffer for capturing last N lines of output. */
|
|
748
1130
|
class RingBuffer {
|
|
@@ -804,14 +1186,23 @@ function resolveCommand(script) {
|
|
|
804
1186
|
* Execute a job script as a child process. Captures output, parses metadata, enforces timeout.
|
|
805
1187
|
*/
|
|
806
1188
|
function executeJob(options) {
|
|
807
|
-
const { script, dbPath, jobId, runId, timeoutMs, commandResolver } = options;
|
|
1189
|
+
const { script, dbPath, jobId, runId, timeoutMs, commandResolver, sourceType = 'path', } = options;
|
|
808
1190
|
const startTime = Date.now();
|
|
1191
|
+
// For inline scripts, write to a temp file and clean up after.
|
|
1192
|
+
let tempFile = null;
|
|
1193
|
+
let effectiveScript = script;
|
|
1194
|
+
if (sourceType === 'inline') {
|
|
1195
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'jr-inline-'));
|
|
1196
|
+
tempFile = join(tempDir, 'inline.js');
|
|
1197
|
+
writeFileSync(tempFile, script);
|
|
1198
|
+
effectiveScript = tempFile;
|
|
1199
|
+
}
|
|
809
1200
|
return new Promise((resolve) => {
|
|
810
1201
|
const stdoutBuffer = new RingBuffer(100);
|
|
811
1202
|
const stderrBuffer = new RingBuffer(100);
|
|
812
1203
|
const { command, args } = commandResolver
|
|
813
|
-
? commandResolver(
|
|
814
|
-
: resolveCommand(
|
|
1204
|
+
? commandResolver(effectiveScript)
|
|
1205
|
+
: resolveCommand(effectiveScript);
|
|
815
1206
|
const child = spawn(command, args, {
|
|
816
1207
|
env: {
|
|
817
1208
|
...process.env,
|
|
@@ -847,6 +1238,7 @@ function executeJob(options) {
|
|
|
847
1238
|
child.on('close', (exitCode) => {
|
|
848
1239
|
if (timeoutHandle)
|
|
849
1240
|
clearTimeout(timeoutHandle);
|
|
1241
|
+
cleanupTempFile(tempFile);
|
|
850
1242
|
const durationMs = Date.now() - startTime;
|
|
851
1243
|
const stdoutTail = stdoutBuffer.getAll();
|
|
852
1244
|
const stderrTail = stderrBuffer.getAll();
|
|
@@ -891,6 +1283,7 @@ function executeJob(options) {
|
|
|
891
1283
|
child.on('error', (err) => {
|
|
892
1284
|
if (timeoutHandle)
|
|
893
1285
|
clearTimeout(timeoutHandle);
|
|
1286
|
+
cleanupTempFile(tempFile);
|
|
894
1287
|
const durationMs = Date.now() - startTime;
|
|
895
1288
|
resolve({
|
|
896
1289
|
status: 'error',
|
|
@@ -905,31 +1298,73 @@ function executeJob(options) {
|
|
|
905
1298
|
});
|
|
906
1299
|
});
|
|
907
1300
|
}
|
|
1301
|
+
/** Remove a temp file created for inline script execution. */
|
|
1302
|
+
function cleanupTempFile(tempFile) {
|
|
1303
|
+
if (!tempFile)
|
|
1304
|
+
return;
|
|
1305
|
+
try {
|
|
1306
|
+
unlinkSync(tempFile);
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
// Ignore cleanup errors
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
908
1312
|
|
|
909
1313
|
/**
|
|
910
|
-
*
|
|
1314
|
+
* Schedule registration and reconciliation. Supports cron (croner) and RRStack schedule formats via unified setTimeout-based scheduling.
|
|
1315
|
+
*
|
|
1316
|
+
* @module
|
|
911
1317
|
*/
|
|
1318
|
+
/** Maximum setTimeout delay (Node.js limit: ~24.8 days). */
|
|
1319
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
912
1320
|
function createCronRegistry(deps) {
|
|
913
1321
|
const { db, logger, onScheduledRun } = deps;
|
|
914
|
-
const
|
|
915
|
-
const
|
|
1322
|
+
const handles = new Map();
|
|
1323
|
+
const scheduleStrings = new Map();
|
|
916
1324
|
const failedRegistrations = new Set();
|
|
917
|
-
|
|
1325
|
+
/**
|
|
1326
|
+
* Schedule the next fire for a job. Computes next fire time, sets a
|
|
1327
|
+
* setTimeout, and re-arms after each fire.
|
|
1328
|
+
*/
|
|
1329
|
+
function scheduleNext(jobId, schedule) {
|
|
1330
|
+
const nextDate = getNextFireTime(schedule);
|
|
1331
|
+
if (!nextDate) {
|
|
1332
|
+
logger.warn({ jobId }, 'No upcoming fire time, job will not fire');
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const delayMs = Math.max(0, nextDate.getTime() - Date.now());
|
|
1336
|
+
// Node.js setTimeout max is ~24.8 days. If delay exceeds that,
|
|
1337
|
+
// set an intermediate wakeup and re-check.
|
|
1338
|
+
const effectiveDelay = Math.min(delayMs, MAX_TIMEOUT_MS);
|
|
1339
|
+
const isIntermediate = delayMs > MAX_TIMEOUT_MS;
|
|
1340
|
+
const timeout = setTimeout(() => {
|
|
1341
|
+
if (isIntermediate) {
|
|
1342
|
+
// Woke up early just to re-check; schedule again.
|
|
1343
|
+
scheduleNext(jobId, schedule);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
// Re-read job from DB to get current configuration
|
|
1347
|
+
const currentJob = db
|
|
1348
|
+
.prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
|
|
1349
|
+
.get(jobId);
|
|
1350
|
+
if (!currentJob) {
|
|
1351
|
+
logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
onScheduledRun(currentJob);
|
|
1355
|
+
// Re-arm for the next occurrence
|
|
1356
|
+
scheduleNext(jobId, schedule);
|
|
1357
|
+
}, effectiveDelay);
|
|
1358
|
+
handles.set(jobId, {
|
|
1359
|
+
cancel: () => {
|
|
1360
|
+
clearTimeout(timeout);
|
|
1361
|
+
},
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
function registerSchedule(job) {
|
|
918
1365
|
try {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
// Re-read job from DB to get current configuration
|
|
922
|
-
const currentJob = db
|
|
923
|
-
.prepare('SELECT * FROM jobs WHERE id = ? AND enabled = 1')
|
|
924
|
-
.get(jobId);
|
|
925
|
-
if (!currentJob) {
|
|
926
|
-
logger.warn({ jobId }, 'Job no longer exists or disabled, skipping');
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
|
-
onScheduledRun(currentJob);
|
|
930
|
-
});
|
|
931
|
-
crons.set(job.id, cron);
|
|
932
|
-
cronSchedules.set(job.id, job.schedule);
|
|
1366
|
+
scheduleNext(job.id, job.schedule);
|
|
1367
|
+
scheduleStrings.set(job.id, job.schedule);
|
|
933
1368
|
failedRegistrations.delete(job.id);
|
|
934
1369
|
logger.info({ jobId: job.id, schedule: job.schedule }, 'Scheduled job');
|
|
935
1370
|
return true;
|
|
@@ -946,39 +1381,39 @@ function createCronRegistry(deps) {
|
|
|
946
1381
|
.all();
|
|
947
1382
|
const enabledById = new Map(enabledJobs.map((j) => [j.id, j]));
|
|
948
1383
|
// Remove disabled/deleted jobs
|
|
949
|
-
for (const [jobId,
|
|
1384
|
+
for (const [jobId, handle] of handles.entries()) {
|
|
950
1385
|
if (!enabledById.has(jobId)) {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1386
|
+
handle.cancel();
|
|
1387
|
+
handles.delete(jobId);
|
|
1388
|
+
scheduleStrings.delete(jobId);
|
|
954
1389
|
}
|
|
955
1390
|
}
|
|
956
1391
|
const failedIds = [];
|
|
957
1392
|
// Add or update enabled jobs
|
|
958
1393
|
for (const job of enabledJobs) {
|
|
959
|
-
const
|
|
960
|
-
const existingSchedule =
|
|
961
|
-
if (!
|
|
962
|
-
if (!
|
|
1394
|
+
const existingHandle = handles.get(job.id);
|
|
1395
|
+
const existingSchedule = scheduleStrings.get(job.id);
|
|
1396
|
+
if (!existingHandle) {
|
|
1397
|
+
if (!registerSchedule(job))
|
|
963
1398
|
failedIds.push(job.id);
|
|
964
1399
|
continue;
|
|
965
1400
|
}
|
|
966
1401
|
if (existingSchedule !== job.schedule) {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
if (!
|
|
1402
|
+
existingHandle.cancel();
|
|
1403
|
+
handles.delete(job.id);
|
|
1404
|
+
scheduleStrings.delete(job.id);
|
|
1405
|
+
if (!registerSchedule(job))
|
|
971
1406
|
failedIds.push(job.id);
|
|
972
1407
|
}
|
|
973
1408
|
}
|
|
974
1409
|
return { totalEnabled: enabledJobs.length, failedIds };
|
|
975
1410
|
}
|
|
976
1411
|
function stopAll() {
|
|
977
|
-
for (const
|
|
978
|
-
|
|
1412
|
+
for (const handle of handles.values()) {
|
|
1413
|
+
handle.cancel();
|
|
979
1414
|
}
|
|
980
|
-
|
|
981
|
-
|
|
1415
|
+
handles.clear();
|
|
1416
|
+
scheduleStrings.clear();
|
|
982
1417
|
}
|
|
983
1418
|
return {
|
|
984
1419
|
reconcile,
|
|
@@ -991,6 +1426,8 @@ function createCronRegistry(deps) {
|
|
|
991
1426
|
|
|
992
1427
|
/**
|
|
993
1428
|
* Run record repository for managing job execution records.
|
|
1429
|
+
*
|
|
1430
|
+
* @module
|
|
994
1431
|
*/
|
|
995
1432
|
/** Create a run repository for the given database connection. */
|
|
996
1433
|
function createRunRepository(db) {
|
|
@@ -1012,6 +1449,8 @@ function createRunRepository(db) {
|
|
|
1012
1449
|
|
|
1013
1450
|
/**
|
|
1014
1451
|
* Session executor for job type='session'. Spawns OpenClaw Gateway sessions and polls for completion.
|
|
1452
|
+
*
|
|
1453
|
+
* @module
|
|
1015
1454
|
*/
|
|
1016
1455
|
/** File extensions that indicate a script rather than a prompt. */
|
|
1017
1456
|
const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ps1', '.cmd', '.bat'];
|
|
@@ -1111,7 +1550,9 @@ async function executeSession(options) {
|
|
|
1111
1550
|
}
|
|
1112
1551
|
|
|
1113
1552
|
/**
|
|
1114
|
-
*
|
|
1553
|
+
* Job scheduler. Loads enabled jobs, registers schedules (cron or rrstack), manages execution, respects overlap policies and concurrency limits.
|
|
1554
|
+
*
|
|
1555
|
+
* @module
|
|
1115
1556
|
*/
|
|
1116
1557
|
// JobRow is imported from cron-registry
|
|
1117
1558
|
/**
|
|
@@ -1131,7 +1572,7 @@ function createScheduler(deps) {
|
|
|
1131
1572
|
let reconcileInterval = null;
|
|
1132
1573
|
/** Execute a job: create run record, run script, update record, send notifications. */
|
|
1133
1574
|
async function runJob(job, trigger) {
|
|
1134
|
-
const { id, name, script, type, timeout_ms, on_success, on_failure } = job;
|
|
1575
|
+
const { id, name, script, type, timeout_ms, on_success, on_failure, source_type, } = job;
|
|
1135
1576
|
// Check concurrency limit
|
|
1136
1577
|
if (runningJobs.size >= config.maxConcurrency) {
|
|
1137
1578
|
logger.warn({ jobId: id }, 'Max concurrency reached, skipping job');
|
|
@@ -1162,6 +1603,7 @@ function createScheduler(deps) {
|
|
|
1162
1603
|
jobId: id,
|
|
1163
1604
|
runId,
|
|
1164
1605
|
timeoutMs: timeout_ms ?? undefined,
|
|
1606
|
+
sourceType: source_type ?? 'path',
|
|
1165
1607
|
});
|
|
1166
1608
|
}
|
|
1167
1609
|
runRepository.finishRun(runId, result);
|
|
@@ -1251,6 +1693,8 @@ function createScheduler(deps) {
|
|
|
1251
1693
|
|
|
1252
1694
|
/**
|
|
1253
1695
|
* Main runner orchestrator. Wires up database, scheduler, API server, and handles graceful shutdown on SIGTERM/SIGINT.
|
|
1696
|
+
*
|
|
1697
|
+
* @module
|
|
1254
1698
|
*/
|
|
1255
1699
|
/**
|
|
1256
1700
|
* Create the runner. Initializes database, scheduler, API server, and sets up graceful shutdown.
|
|
@@ -1315,14 +1759,26 @@ function createRunner(config, deps) {
|
|
|
1315
1759
|
});
|
|
1316
1760
|
scheduler.start();
|
|
1317
1761
|
logger.info('Scheduler started');
|
|
1762
|
+
// Read package version
|
|
1763
|
+
const pkgVersion = (() => {
|
|
1764
|
+
try {
|
|
1765
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
1766
|
+
const pkg = JSON.parse(readFileSync(resolve(dir, '..', 'package.json'), 'utf8'));
|
|
1767
|
+
return pkg.version ?? 'unknown';
|
|
1768
|
+
}
|
|
1769
|
+
catch {
|
|
1770
|
+
return 'unknown';
|
|
1771
|
+
}
|
|
1772
|
+
})();
|
|
1318
1773
|
// API server
|
|
1319
1774
|
server = createServer({
|
|
1320
1775
|
db,
|
|
1321
1776
|
scheduler,
|
|
1322
1777
|
getConfig: () => config,
|
|
1778
|
+
version: pkgVersion,
|
|
1323
1779
|
loggerConfig: { level: config.log.level, file: config.log.file },
|
|
1324
1780
|
});
|
|
1325
|
-
await server.listen({ port: config.port, host:
|
|
1781
|
+
await server.listen({ port: config.port, host: config.host });
|
|
1326
1782
|
logger.info({ port: config.port }, 'API server listening');
|
|
1327
1783
|
// Graceful shutdown
|
|
1328
1784
|
const shutdown = async (signal) => {
|
|
@@ -1361,6 +1817,8 @@ function createRunner(config, deps) {
|
|
|
1361
1817
|
|
|
1362
1818
|
/**
|
|
1363
1819
|
* Queue operations module for runner client. Provides enqueue, dequeue, done, and fail operations.
|
|
1820
|
+
*
|
|
1821
|
+
* @module
|
|
1364
1822
|
*/
|
|
1365
1823
|
/** Create queue operations for the given database connection. */
|
|
1366
1824
|
function createQueueOps(db) {
|
|
@@ -1463,6 +1921,8 @@ function createQueueOps(db) {
|
|
|
1463
1921
|
|
|
1464
1922
|
/**
|
|
1465
1923
|
* State operations module for runner client. Provides scalar state (key-value) and collection state (grouped items).
|
|
1924
|
+
*
|
|
1925
|
+
* @module
|
|
1466
1926
|
*/
|
|
1467
1927
|
/** Parse TTL string (e.g., '30d', '24h', '60m') into ISO datetime offset from now. */
|
|
1468
1928
|
function parseTtl(ttl) {
|
|
@@ -1568,6 +2028,8 @@ function createCollectionOps(db) {
|
|
|
1568
2028
|
|
|
1569
2029
|
/**
|
|
1570
2030
|
* Job client library for runner jobs. Provides state and queue operations. Opens its own DB connection via JR_DB_PATH env var.
|
|
2031
|
+
*
|
|
2032
|
+
* @module
|
|
1571
2033
|
*/
|
|
1572
2034
|
/**
|
|
1573
2035
|
* Create a runner client for job scripts. Opens its own DB connection.
|
|
@@ -1590,4 +2052,4 @@ function createClient(dbPath) {
|
|
|
1590
2052
|
};
|
|
1591
2053
|
}
|
|
1592
2054
|
|
|
1593
|
-
export { closeConnection, createClient, createConnection, createGatewayClient, createMaintenance, createNotifier, createRunner, createScheduler, executeJob, executeSession, jobSchema, queueSchema, runMigrations, runSchema, runStatusSchema, runTriggerSchema, runnerConfigSchema };
|
|
2055
|
+
export { closeConnection, createClient, createConnection, createGatewayClient, createMaintenance, createNotifier, createRunner, createScheduler, executeJob, executeSession, getNextFireTime, jobSchema, queueSchema, runMigrations, runSchema, runStatusSchema, runTriggerSchema, runnerConfigSchema, validateSchedule };
|