jm2 0.1.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/GNU-AGPL-3.0 +665 -0
- package/README.md +603 -0
- package/bin/jm2.js +24 -0
- package/package.json +70 -0
- package/src/cli/commands/add.js +206 -0
- package/src/cli/commands/config.js +212 -0
- package/src/cli/commands/edit.js +198 -0
- package/src/cli/commands/export.js +61 -0
- package/src/cli/commands/flush.js +132 -0
- package/src/cli/commands/history.js +179 -0
- package/src/cli/commands/import.js +180 -0
- package/src/cli/commands/list.js +174 -0
- package/src/cli/commands/logs.js +415 -0
- package/src/cli/commands/pause.js +97 -0
- package/src/cli/commands/remove.js +107 -0
- package/src/cli/commands/restart.js +68 -0
- package/src/cli/commands/resume.js +96 -0
- package/src/cli/commands/run.js +115 -0
- package/src/cli/commands/show.js +159 -0
- package/src/cli/commands/start.js +46 -0
- package/src/cli/commands/status.js +47 -0
- package/src/cli/commands/stop.js +48 -0
- package/src/cli/index.js +274 -0
- package/src/cli/utils/output.js +267 -0
- package/src/cli/utils/prompts.js +56 -0
- package/src/core/config.js +227 -0
- package/src/core/history-db.js +439 -0
- package/src/core/job.js +329 -0
- package/src/core/logger.js +382 -0
- package/src/core/storage.js +315 -0
- package/src/daemon/executor.js +409 -0
- package/src/daemon/index.js +873 -0
- package/src/daemon/scheduler.js +465 -0
- package/src/ipc/client.js +112 -0
- package/src/ipc/protocol.js +183 -0
- package/src/ipc/server.js +92 -0
- package/src/utils/cron.js +205 -0
- package/src/utils/datetime.js +237 -0
- package/src/utils/duration.js +226 -0
- package/src/utils/paths.js +164 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon entry point for JM2
|
|
3
|
+
* Handles daemonization, PID management, and graceful shutdown
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFileSync, unlinkSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { getPidFile, ensureDataDir, getSocketPath, getLogsDir, getJobLogFile } from '../utils/paths.js';
|
|
11
|
+
import { getJobs, saveJobs, getHistory, saveHistory } from '../core/storage.js';
|
|
12
|
+
import { getConfig } from '../core/config.js';
|
|
13
|
+
import { createDaemonLogger } from '../core/logger.js';
|
|
14
|
+
import { startIpcServer, stopIpcServer } from '../ipc/server.js';
|
|
15
|
+
import { createScheduler } from './scheduler.js';
|
|
16
|
+
import { createExecutor } from './executor.js';
|
|
17
|
+
import { createJob, validateJob, normalizeJob, JobStatus } from '../core/job.js';
|
|
18
|
+
import {
|
|
19
|
+
MessageType,
|
|
20
|
+
createJobAddedResponse,
|
|
21
|
+
createJobListResponse,
|
|
22
|
+
createJobGetResponse,
|
|
23
|
+
createJobRemovedResponse,
|
|
24
|
+
createJobUpdatedResponse,
|
|
25
|
+
createJobPausedResponse,
|
|
26
|
+
createJobResumedResponse,
|
|
27
|
+
createJobRunResponse,
|
|
28
|
+
createFlushResultResponse,
|
|
29
|
+
} from '../ipc/protocol.js';
|
|
30
|
+
|
|
31
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
32
|
+
const __dirname = dirname(__filename);
|
|
33
|
+
|
|
34
|
+
let logger = null;
|
|
35
|
+
let ipcServer = null;
|
|
36
|
+
let scheduler = null;
|
|
37
|
+
let isShuttingDown = false;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write PID file
|
|
41
|
+
* @returns {boolean} True if successful
|
|
42
|
+
*/
|
|
43
|
+
export function writePidFile() {
|
|
44
|
+
try {
|
|
45
|
+
ensureDataDir();
|
|
46
|
+
writeFileSync(getPidFile(), process.pid.toString(), 'utf8');
|
|
47
|
+
return true;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error(`Failed to write PID file: ${error.message}`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Remove PID file
|
|
56
|
+
*/
|
|
57
|
+
export function removePidFile() {
|
|
58
|
+
try {
|
|
59
|
+
if (existsSync(getPidFile())) {
|
|
60
|
+
unlinkSync(getPidFile());
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logger?.error(`Failed to remove PID file: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read PID from PID file
|
|
69
|
+
* @returns {number|null} PID or null if not found
|
|
70
|
+
*/
|
|
71
|
+
export function readPidFile() {
|
|
72
|
+
try {
|
|
73
|
+
if (!existsSync(getPidFile())) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const pid = parseInt(readFileSync(getPidFile(), 'utf8'), 10);
|
|
77
|
+
return isNaN(pid) ? null : pid;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if daemon is running
|
|
85
|
+
* @returns {boolean} True if daemon is running
|
|
86
|
+
*/
|
|
87
|
+
export function isDaemonRunning() {
|
|
88
|
+
const pid = readPidFile();
|
|
89
|
+
if (!pid) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Check if process exists (sends signal 0 - doesn't actually signal)
|
|
95
|
+
process.kill(pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Process doesn't exist
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get daemon status
|
|
105
|
+
* @returns {{ running: boolean, pid: number|null }} Status object
|
|
106
|
+
*/
|
|
107
|
+
export function getDaemonStatus() {
|
|
108
|
+
const pid = readPidFile();
|
|
109
|
+
const running = pid !== null && isDaemonRunning();
|
|
110
|
+
return { running, pid: running ? pid : null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stop the daemon
|
|
115
|
+
* @param {number} [signal=15] Signal to send (default: SIGTERM)
|
|
116
|
+
* @returns {boolean} True if stop signal was sent
|
|
117
|
+
*/
|
|
118
|
+
export function stopDaemon(signal = 15) {
|
|
119
|
+
const pid = readPidFile();
|
|
120
|
+
if (!pid) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, signal);
|
|
126
|
+
return true;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle shutdown signals
|
|
134
|
+
*/
|
|
135
|
+
function handleShutdown() {
|
|
136
|
+
if (isShuttingDown) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
isShuttingDown = true;
|
|
140
|
+
|
|
141
|
+
logger?.info('Shutting down daemon...');
|
|
142
|
+
|
|
143
|
+
// Stop scheduler
|
|
144
|
+
if (scheduler) {
|
|
145
|
+
scheduler.stop();
|
|
146
|
+
scheduler = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Stop IPC server
|
|
150
|
+
if (ipcServer) {
|
|
151
|
+
stopIpcServer(ipcServer);
|
|
152
|
+
ipcServer = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Remove PID file
|
|
156
|
+
removePidFile();
|
|
157
|
+
|
|
158
|
+
logger?.info('Daemon stopped');
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start the daemon process
|
|
164
|
+
* @param {object} options - Daemon options
|
|
165
|
+
* @param {boolean} options.foreground - Run in foreground (don't daemonize)
|
|
166
|
+
* @returns {Promise<void>}
|
|
167
|
+
*/
|
|
168
|
+
export async function startDaemon(options = {}) {
|
|
169
|
+
const { foreground = false } = options;
|
|
170
|
+
|
|
171
|
+
// Check if already running
|
|
172
|
+
if (isDaemonRunning()) {
|
|
173
|
+
const { pid } = getDaemonStatus();
|
|
174
|
+
throw new Error(`Daemon is already running (PID: ${pid})`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (foreground) {
|
|
178
|
+
// Run in foreground mode
|
|
179
|
+
await runDaemon({ foreground: true });
|
|
180
|
+
} else {
|
|
181
|
+
// Daemonize - spawn detached process
|
|
182
|
+
const scriptPath = join(__dirname, 'index.js');
|
|
183
|
+
|
|
184
|
+
const child = spawn(process.execPath, [scriptPath, '--foreground'], {
|
|
185
|
+
detached: true,
|
|
186
|
+
stdio: 'ignore',
|
|
187
|
+
env: { ...process.env, JM2_DAEMONIZED: '1' },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
child.unref();
|
|
191
|
+
|
|
192
|
+
// Wait a moment to check if process started
|
|
193
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
194
|
+
|
|
195
|
+
// Check if daemon started successfully
|
|
196
|
+
if (!isDaemonRunning()) {
|
|
197
|
+
throw new Error('Failed to start daemon');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log(`Daemon started (PID: ${readPidFile()})`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run the daemon (internal - called by startDaemon)
|
|
206
|
+
* @param {object} options - Run options
|
|
207
|
+
* @param {boolean} options.foreground - Run in foreground
|
|
208
|
+
*/
|
|
209
|
+
async function runDaemon(options = {}) {
|
|
210
|
+
const { foreground = false } = options;
|
|
211
|
+
|
|
212
|
+
// Write PID file
|
|
213
|
+
if (!writePidFile()) {
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Initialize logger
|
|
218
|
+
logger = createDaemonLogger({ foreground });
|
|
219
|
+
logger.info('Daemon starting...');
|
|
220
|
+
|
|
221
|
+
// Setup shutdown handlers
|
|
222
|
+
process.on('SIGTERM', handleShutdown);
|
|
223
|
+
process.on('SIGINT', handleShutdown);
|
|
224
|
+
process.on('exit', () => {
|
|
225
|
+
removePidFile();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Handle uncaught errors
|
|
229
|
+
process.on('uncaughtException', error => {
|
|
230
|
+
logger.error(`Uncaught exception: ${error.message}`);
|
|
231
|
+
logger.error(error.stack);
|
|
232
|
+
handleShutdown();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
236
|
+
logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Create executor
|
|
240
|
+
const executor = createExecutor({ logger });
|
|
241
|
+
logger.info('Executor created');
|
|
242
|
+
|
|
243
|
+
// Start scheduler
|
|
244
|
+
try {
|
|
245
|
+
const config = getConfig();
|
|
246
|
+
scheduler = createScheduler({
|
|
247
|
+
logger,
|
|
248
|
+
executor,
|
|
249
|
+
maxConcurrent: config.daemon?.maxConcurrent || 10,
|
|
250
|
+
});
|
|
251
|
+
scheduler.start();
|
|
252
|
+
logger.info('Scheduler started');
|
|
253
|
+
} catch (error) {
|
|
254
|
+
logger.error(`Failed to start scheduler: ${error.message}`);
|
|
255
|
+
handleShutdown();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Start IPC server
|
|
260
|
+
try {
|
|
261
|
+
ipcServer = startIpcServer({
|
|
262
|
+
onMessage: handleIpcMessage,
|
|
263
|
+
});
|
|
264
|
+
logger.info('IPC server started');
|
|
265
|
+
} catch (error) {
|
|
266
|
+
logger.error(`Failed to start IPC server: ${error.message}`);
|
|
267
|
+
handleShutdown();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
logger.info('Daemon ready');
|
|
272
|
+
|
|
273
|
+
// Keep process alive
|
|
274
|
+
if (foreground) {
|
|
275
|
+
// In foreground mode, we can keep the event loop alive
|
|
276
|
+
setInterval(() => {}, 1000);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Handle IPC messages
|
|
282
|
+
* @param {object} message - Incoming message
|
|
283
|
+
* @returns {Promise<object|null>} Response message
|
|
284
|
+
*/
|
|
285
|
+
async function handleIpcMessage(message) {
|
|
286
|
+
logger?.debug(`Received message: ${JSON.stringify(message)}`);
|
|
287
|
+
|
|
288
|
+
switch (message.type) {
|
|
289
|
+
case 'status':
|
|
290
|
+
return {
|
|
291
|
+
type: 'status',
|
|
292
|
+
running: true,
|
|
293
|
+
pid: process.pid,
|
|
294
|
+
stats: scheduler ? scheduler.getStats() : null,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
case 'stop':
|
|
298
|
+
logger?.info('Stop requested via IPC');
|
|
299
|
+
setTimeout(handleShutdown, 100);
|
|
300
|
+
return {
|
|
301
|
+
type: 'stopped',
|
|
302
|
+
message: 'Daemon is stopping',
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
case MessageType.JOB_ADD:
|
|
306
|
+
return handleJobAdd(message);
|
|
307
|
+
|
|
308
|
+
case MessageType.JOB_LIST:
|
|
309
|
+
return handleJobList(message);
|
|
310
|
+
|
|
311
|
+
case MessageType.JOB_GET:
|
|
312
|
+
return handleJobGet(message);
|
|
313
|
+
|
|
314
|
+
case MessageType.JOB_REMOVE:
|
|
315
|
+
return handleJobRemove(message);
|
|
316
|
+
|
|
317
|
+
case MessageType.JOB_UPDATE:
|
|
318
|
+
return handleJobUpdate(message);
|
|
319
|
+
|
|
320
|
+
case MessageType.JOB_PAUSE:
|
|
321
|
+
return handleJobPause(message);
|
|
322
|
+
|
|
323
|
+
case MessageType.JOB_RESUME:
|
|
324
|
+
return handleJobResume(message);
|
|
325
|
+
|
|
326
|
+
case MessageType.JOB_RUN:
|
|
327
|
+
return handleJobRun(message);
|
|
328
|
+
|
|
329
|
+
case MessageType.FLUSH:
|
|
330
|
+
return handleFlush(message);
|
|
331
|
+
|
|
332
|
+
case MessageType.RELOAD_JOBS:
|
|
333
|
+
return handleReloadJobs(message);
|
|
334
|
+
|
|
335
|
+
default:
|
|
336
|
+
return {
|
|
337
|
+
type: 'error',
|
|
338
|
+
message: `Unknown message type: ${message.type}`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Handle job add message
|
|
345
|
+
* @param {object} message - Message with jobData
|
|
346
|
+
* @returns {object} Response
|
|
347
|
+
*/
|
|
348
|
+
function handleJobAdd(message) {
|
|
349
|
+
try {
|
|
350
|
+
const jobData = message.jobData;
|
|
351
|
+
|
|
352
|
+
// Create job with defaults
|
|
353
|
+
let job = createJob(jobData);
|
|
354
|
+
|
|
355
|
+
// Validate job
|
|
356
|
+
const validation = validateJob(job);
|
|
357
|
+
if (!validation.valid) {
|
|
358
|
+
return {
|
|
359
|
+
type: MessageType.ERROR,
|
|
360
|
+
message: `Invalid job: ${validation.errors.join(', ')}`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Normalize job
|
|
365
|
+
job = normalizeJob(job);
|
|
366
|
+
|
|
367
|
+
// Add to scheduler
|
|
368
|
+
const addedJob = scheduler.addJob(job);
|
|
369
|
+
|
|
370
|
+
logger?.info(`Job added via IPC: ${addedJob.id} (${addedJob.name || 'unnamed'})`);
|
|
371
|
+
return createJobAddedResponse(addedJob);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
logger?.error(`Failed to add job: ${error.message}`);
|
|
374
|
+
return {
|
|
375
|
+
type: MessageType.ERROR,
|
|
376
|
+
message: `Failed to add job: ${error.message}`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Handle job list message
|
|
383
|
+
* @param {object} message - Message with optional filters
|
|
384
|
+
* @returns {object} Response
|
|
385
|
+
*/
|
|
386
|
+
function handleJobList(message) {
|
|
387
|
+
try {
|
|
388
|
+
let jobs = scheduler.getAllJobs();
|
|
389
|
+
|
|
390
|
+
// Apply filters if provided
|
|
391
|
+
if (message.filters) {
|
|
392
|
+
const { status, tag, type } = message.filters;
|
|
393
|
+
|
|
394
|
+
if (status) {
|
|
395
|
+
jobs = jobs.filter(j => j.status === status);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (tag) {
|
|
399
|
+
jobs = jobs.filter(j => j.tags && j.tags.includes(tag.toLowerCase()));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (type) {
|
|
403
|
+
jobs = jobs.filter(j => j.type === type);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return createJobListResponse(jobs);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
logger?.error(`Failed to list jobs: ${error.message}`);
|
|
410
|
+
return {
|
|
411
|
+
type: MessageType.ERROR,
|
|
412
|
+
message: `Failed to list jobs: ${error.message}`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Handle job get message
|
|
419
|
+
* @param {object} message - Message with jobId or jobName
|
|
420
|
+
* @returns {object} Response
|
|
421
|
+
*/
|
|
422
|
+
function handleJobGet(message) {
|
|
423
|
+
try {
|
|
424
|
+
let job = null;
|
|
425
|
+
|
|
426
|
+
if (message.jobId) {
|
|
427
|
+
job = scheduler.getJob(message.jobId);
|
|
428
|
+
} else if (message.jobName) {
|
|
429
|
+
// Find by name
|
|
430
|
+
const jobs = scheduler.getAllJobs();
|
|
431
|
+
job = jobs.find(j => j.name === message.jobName) || null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return createJobGetResponse(job);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
logger?.error(`Failed to get job: ${error.message}`);
|
|
437
|
+
return {
|
|
438
|
+
type: MessageType.ERROR,
|
|
439
|
+
message: `Failed to get job: ${error.message}`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Handle job remove message
|
|
446
|
+
* @param {object} message - Message with jobId
|
|
447
|
+
* @returns {object} Response
|
|
448
|
+
*/
|
|
449
|
+
function handleJobRemove(message) {
|
|
450
|
+
try {
|
|
451
|
+
let jobId = message.jobId;
|
|
452
|
+
let jobName = null;
|
|
453
|
+
|
|
454
|
+
// If name is provided instead of ID, look it up
|
|
455
|
+
if (!jobId && message.jobName) {
|
|
456
|
+
const jobs = scheduler.getAllJobs();
|
|
457
|
+
const job = jobs.find(j => j.name === message.jobName);
|
|
458
|
+
if (job) {
|
|
459
|
+
jobId = job.id;
|
|
460
|
+
jobName = job.name;
|
|
461
|
+
}
|
|
462
|
+
} else if (jobId) {
|
|
463
|
+
// Get job name for log file deletion
|
|
464
|
+
const job = scheduler.getJob(jobId);
|
|
465
|
+
if (job) {
|
|
466
|
+
jobName = job.name;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!jobId) {
|
|
471
|
+
return {
|
|
472
|
+
type: MessageType.ERROR,
|
|
473
|
+
message: 'Job ID or name is required',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const success = scheduler.removeJob(jobId);
|
|
478
|
+
|
|
479
|
+
if (success) {
|
|
480
|
+
logger?.info(`Job removed via IPC: ${jobId}`);
|
|
481
|
+
|
|
482
|
+
// Delete the job's log file
|
|
483
|
+
const logFile = getJobLogFile(jobName || `job-${jobId}`);
|
|
484
|
+
if (existsSync(logFile)) {
|
|
485
|
+
try {
|
|
486
|
+
unlinkSync(logFile);
|
|
487
|
+
logger?.info(`Deleted log file: ${logFile}`);
|
|
488
|
+
} catch (err) {
|
|
489
|
+
logger?.warn(`Failed to delete log file: ${logFile} - ${err.message}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return createJobRemovedResponse(success);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
logger?.error(`Failed to remove job: ${error.message}`);
|
|
497
|
+
return {
|
|
498
|
+
type: MessageType.ERROR,
|
|
499
|
+
message: `Failed to remove job: ${error.message}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Handle job update message
|
|
506
|
+
* @param {object} message - Message with jobId and updates
|
|
507
|
+
* @returns {object} Response
|
|
508
|
+
*/
|
|
509
|
+
function handleJobUpdate(message) {
|
|
510
|
+
try {
|
|
511
|
+
let jobId = message.jobId;
|
|
512
|
+
|
|
513
|
+
// If name is provided instead of ID, look it up
|
|
514
|
+
if (!jobId && message.jobName) {
|
|
515
|
+
const jobs = scheduler.getAllJobs();
|
|
516
|
+
const job = jobs.find(j => j.name === message.jobName);
|
|
517
|
+
if (job) {
|
|
518
|
+
jobId = job.id;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!jobId) {
|
|
523
|
+
return {
|
|
524
|
+
type: MessageType.ERROR,
|
|
525
|
+
message: 'Job not found',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const updatedJob = scheduler.updateJob(jobId, message.updates);
|
|
530
|
+
|
|
531
|
+
if (updatedJob) {
|
|
532
|
+
logger?.info(`Job updated via IPC: ${jobId}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return createJobUpdatedResponse(updatedJob);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
logger?.error(`Failed to update job: ${error.message}`);
|
|
538
|
+
return {
|
|
539
|
+
type: MessageType.ERROR,
|
|
540
|
+
message: `Failed to update job: ${error.message}`,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Handle job pause message
|
|
547
|
+
* @param {object} message - Message with jobId
|
|
548
|
+
* @returns {object} Response
|
|
549
|
+
*/
|
|
550
|
+
function handleJobPause(message) {
|
|
551
|
+
try {
|
|
552
|
+
let jobId = message.jobId;
|
|
553
|
+
|
|
554
|
+
// If name is provided instead of ID, look it up
|
|
555
|
+
if (!jobId && message.jobName) {
|
|
556
|
+
const jobs = scheduler.getAllJobs();
|
|
557
|
+
const job = jobs.find(j => j.name === message.jobName);
|
|
558
|
+
if (job) {
|
|
559
|
+
jobId = job.id;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!jobId) {
|
|
564
|
+
return {
|
|
565
|
+
type: MessageType.ERROR,
|
|
566
|
+
message: 'Job not found',
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const pausedJob = scheduler.updateJobStatus(jobId, JobStatus.PAUSED);
|
|
571
|
+
|
|
572
|
+
if (pausedJob) {
|
|
573
|
+
logger?.info(`Job paused via IPC: ${jobId}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return createJobPausedResponse(pausedJob);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
logger?.error(`Failed to pause job: ${error.message}`);
|
|
579
|
+
return {
|
|
580
|
+
type: MessageType.ERROR,
|
|
581
|
+
message: `Failed to pause job: ${error.message}`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Handle job resume message
|
|
588
|
+
* @param {object} message - Message with jobId
|
|
589
|
+
* @returns {object} Response
|
|
590
|
+
*/
|
|
591
|
+
function handleJobResume(message) {
|
|
592
|
+
try {
|
|
593
|
+
let jobId = message.jobId;
|
|
594
|
+
|
|
595
|
+
// If name is provided instead of ID, look it up
|
|
596
|
+
if (!jobId && message.jobName) {
|
|
597
|
+
const jobs = scheduler.getAllJobs();
|
|
598
|
+
const job = jobs.find(j => j.name === message.jobName);
|
|
599
|
+
if (job) {
|
|
600
|
+
jobId = job.id;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (!jobId) {
|
|
605
|
+
return {
|
|
606
|
+
type: MessageType.ERROR,
|
|
607
|
+
message: 'Job not found',
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const resumedJob = scheduler.updateJobStatus(jobId, JobStatus.ACTIVE);
|
|
612
|
+
|
|
613
|
+
if (resumedJob) {
|
|
614
|
+
logger?.info(`Job resumed via IPC: ${jobId}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return createJobResumedResponse(resumedJob);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
logger?.error(`Failed to resume job: ${error.message}`);
|
|
620
|
+
return {
|
|
621
|
+
type: MessageType.ERROR,
|
|
622
|
+
message: `Failed to resume job: ${error.message}`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Handle job run message (manual execution)
|
|
629
|
+
* @param {object} message - Message with jobId
|
|
630
|
+
* @returns {object} Response
|
|
631
|
+
*/
|
|
632
|
+
async function handleJobRun(message) {
|
|
633
|
+
try {
|
|
634
|
+
let jobId = message.jobId;
|
|
635
|
+
|
|
636
|
+
// If name is provided instead of ID, look it up
|
|
637
|
+
if (!jobId && message.jobName) {
|
|
638
|
+
const jobs = scheduler.getAllJobs();
|
|
639
|
+
const job = jobs.find(j => j.name === message.jobName);
|
|
640
|
+
if (job) {
|
|
641
|
+
jobId = job.id;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!jobId) {
|
|
646
|
+
return {
|
|
647
|
+
type: MessageType.ERROR,
|
|
648
|
+
message: 'Job not found',
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const job = scheduler.getJob(jobId);
|
|
653
|
+
if (!job) {
|
|
654
|
+
return {
|
|
655
|
+
type: MessageType.ERROR,
|
|
656
|
+
message: 'Job not found',
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
logger?.info(`Manual job run requested via IPC: ${jobId}`);
|
|
661
|
+
|
|
662
|
+
// Execute the job
|
|
663
|
+
if (message.wait) {
|
|
664
|
+
// Wait for execution and return results
|
|
665
|
+
const result = await executeJobAndReturnResult(job);
|
|
666
|
+
return createJobRunResponse({
|
|
667
|
+
jobId,
|
|
668
|
+
status: result.status,
|
|
669
|
+
exitCode: result.exitCode,
|
|
670
|
+
stdout: result.stdout,
|
|
671
|
+
stderr: result.stderr,
|
|
672
|
+
duration: result.duration,
|
|
673
|
+
error: result.error,
|
|
674
|
+
});
|
|
675
|
+
} else {
|
|
676
|
+
// Execute asynchronously (don't wait)
|
|
677
|
+
scheduler.executeJob(job);
|
|
678
|
+
return createJobRunResponse({
|
|
679
|
+
jobId,
|
|
680
|
+
status: 'queued',
|
|
681
|
+
message: 'Job queued for execution',
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
} catch (error) {
|
|
685
|
+
logger?.error(`Failed to run job: ${error.message}`);
|
|
686
|
+
return {
|
|
687
|
+
type: MessageType.ERROR,
|
|
688
|
+
message: `Failed to run job: ${error.message}`,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Execute a job and return the result
|
|
695
|
+
* @param {object} job - Job to execute
|
|
696
|
+
* @returns {Promise<object>} Execution result
|
|
697
|
+
*/
|
|
698
|
+
async function executeJobAndReturnResult(job) {
|
|
699
|
+
if (!scheduler.executor) {
|
|
700
|
+
return {
|
|
701
|
+
status: 'failed',
|
|
702
|
+
error: 'No executor configured',
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
const result = await scheduler.executor.executeJobWithRetry(job);
|
|
708
|
+
|
|
709
|
+
// Update job stats
|
|
710
|
+
const updatedJob = scheduler.getJob(job.id);
|
|
711
|
+
if (updatedJob) {
|
|
712
|
+
updatedJob.runCount = (updatedJob.runCount || 0) + 1;
|
|
713
|
+
updatedJob.lastRun = new Date().toISOString();
|
|
714
|
+
updatedJob.lastResult = result.status === 'success' ? 'success' : 'failed';
|
|
715
|
+
scheduler.persistJobs();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
logger?.info(`Job ${job.id} completed: ${result.status}`);
|
|
719
|
+
return result;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
logger?.error(`Job ${job.id} failed: ${error.message}`);
|
|
722
|
+
|
|
723
|
+
// Update job stats
|
|
724
|
+
const updatedJob = scheduler.getJob(job.id);
|
|
725
|
+
if (updatedJob) {
|
|
726
|
+
updatedJob.runCount = (updatedJob.runCount || 0) + 1;
|
|
727
|
+
updatedJob.lastRun = new Date().toISOString();
|
|
728
|
+
updatedJob.lastResult = 'failed';
|
|
729
|
+
scheduler.persistJobs();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
status: 'failed',
|
|
734
|
+
error: error.message,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Handle flush message
|
|
741
|
+
* @param {object} message - Flush request with options
|
|
742
|
+
* @returns {object} Response with flush results
|
|
743
|
+
*/
|
|
744
|
+
function handleFlush(message) {
|
|
745
|
+
try {
|
|
746
|
+
const result = {
|
|
747
|
+
jobsRemoved: 0,
|
|
748
|
+
logsRemoved: 0,
|
|
749
|
+
historyRemoved: 0,
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// Flush completed one-time jobs
|
|
753
|
+
if (message.jobs !== false) {
|
|
754
|
+
const jobs = getJobs();
|
|
755
|
+
const initialCount = jobs.length;
|
|
756
|
+
const filteredJobs = jobs.filter(job => {
|
|
757
|
+
// Keep non-completed jobs, keep cron jobs even if completed
|
|
758
|
+
if (job.type !== 'once') {
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
return job.status !== 'completed';
|
|
762
|
+
});
|
|
763
|
+
result.jobsRemoved = initialCount - filteredJobs.length;
|
|
764
|
+
if (result.jobsRemoved > 0) {
|
|
765
|
+
saveJobs(filteredJobs);
|
|
766
|
+
logger?.info(`Flushed ${result.jobsRemoved} completed one-time jobs`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Flush old logs
|
|
771
|
+
if (message.logs) {
|
|
772
|
+
const logsDir = getLogsDir();
|
|
773
|
+
if (existsSync(logsDir)) {
|
|
774
|
+
const files = readdirSync(logsDir);
|
|
775
|
+
const now = Date.now();
|
|
776
|
+
const ageMs = message.logsAgeMs || 0; // 0 means all logs
|
|
777
|
+
|
|
778
|
+
for (const file of files) {
|
|
779
|
+
if (file.endsWith('.log')) {
|
|
780
|
+
const filePath = `${logsDir}/${file}`;
|
|
781
|
+
const stats = statSync(filePath);
|
|
782
|
+
const fileAge = now - stats.mtime.getTime();
|
|
783
|
+
|
|
784
|
+
if (ageMs === 0 || fileAge > ageMs) {
|
|
785
|
+
unlinkSync(filePath);
|
|
786
|
+
result.logsRemoved++;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (result.logsRemoved > 0) {
|
|
791
|
+
logger?.info(`Flushed ${result.logsRemoved} log files`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Flush old history
|
|
797
|
+
if (message.history) {
|
|
798
|
+
const history = getHistory();
|
|
799
|
+
const initialCount = history.length;
|
|
800
|
+
|
|
801
|
+
if (message.historyAgeMs && message.historyAgeMs > 0) {
|
|
802
|
+
// Remove entries older than specified age
|
|
803
|
+
const cutoff = new Date(Date.now() - message.historyAgeMs).toISOString();
|
|
804
|
+
const filtered = history.filter(entry => entry.timestamp >= cutoff);
|
|
805
|
+
result.historyRemoved = initialCount - filtered.length;
|
|
806
|
+
if (result.historyRemoved > 0) {
|
|
807
|
+
saveHistory(filtered);
|
|
808
|
+
}
|
|
809
|
+
} else {
|
|
810
|
+
// Remove all history
|
|
811
|
+
result.historyRemoved = initialCount;
|
|
812
|
+
if (result.historyRemoved > 0) {
|
|
813
|
+
saveHistory([]);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (result.historyRemoved > 0) {
|
|
817
|
+
logger?.info(`Flushed ${result.historyRemoved} history entries`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return createFlushResultResponse(result);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
logger?.error(`Failed to flush: ${error.message}`);
|
|
824
|
+
return {
|
|
825
|
+
type: MessageType.ERROR,
|
|
826
|
+
message: `Failed to flush: ${error.message}`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Handle reload jobs message
|
|
833
|
+
* Reloads jobs from storage into scheduler
|
|
834
|
+
* @returns {object} Response
|
|
835
|
+
*/
|
|
836
|
+
function handleReloadJobs() {
|
|
837
|
+
try {
|
|
838
|
+
scheduler.loadJobs();
|
|
839
|
+
const jobCount = scheduler.getAllJobs().length;
|
|
840
|
+
logger?.info(`Reloaded ${jobCount} jobs from storage`);
|
|
841
|
+
return {
|
|
842
|
+
type: MessageType.RELOAD_JOBS_RESULT,
|
|
843
|
+
count: jobCount,
|
|
844
|
+
};
|
|
845
|
+
} catch (error) {
|
|
846
|
+
logger?.error(`Failed to reload jobs: ${error.message}`);
|
|
847
|
+
return {
|
|
848
|
+
type: MessageType.ERROR,
|
|
849
|
+
message: `Failed to reload jobs: ${error.message}`,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// If this file is run directly
|
|
855
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
856
|
+
// Check for foreground flag
|
|
857
|
+
const foreground = process.argv.includes('--foreground');
|
|
858
|
+
|
|
859
|
+
runDaemon({ foreground }).catch(error => {
|
|
860
|
+
console.error(`Daemon error: ${error.message}`);
|
|
861
|
+
process.exit(1);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export default {
|
|
866
|
+
startDaemon,
|
|
867
|
+
stopDaemon,
|
|
868
|
+
isDaemonRunning,
|
|
869
|
+
getDaemonStatus,
|
|
870
|
+
writePidFile,
|
|
871
|
+
removePidFile,
|
|
872
|
+
readPidFile,
|
|
873
|
+
};
|