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.
Files changed (40) hide show
  1. package/GNU-AGPL-3.0 +665 -0
  2. package/README.md +603 -0
  3. package/bin/jm2.js +24 -0
  4. package/package.json +70 -0
  5. package/src/cli/commands/add.js +206 -0
  6. package/src/cli/commands/config.js +212 -0
  7. package/src/cli/commands/edit.js +198 -0
  8. package/src/cli/commands/export.js +61 -0
  9. package/src/cli/commands/flush.js +132 -0
  10. package/src/cli/commands/history.js +179 -0
  11. package/src/cli/commands/import.js +180 -0
  12. package/src/cli/commands/list.js +174 -0
  13. package/src/cli/commands/logs.js +415 -0
  14. package/src/cli/commands/pause.js +97 -0
  15. package/src/cli/commands/remove.js +107 -0
  16. package/src/cli/commands/restart.js +68 -0
  17. package/src/cli/commands/resume.js +96 -0
  18. package/src/cli/commands/run.js +115 -0
  19. package/src/cli/commands/show.js +159 -0
  20. package/src/cli/commands/start.js +46 -0
  21. package/src/cli/commands/status.js +47 -0
  22. package/src/cli/commands/stop.js +48 -0
  23. package/src/cli/index.js +274 -0
  24. package/src/cli/utils/output.js +267 -0
  25. package/src/cli/utils/prompts.js +56 -0
  26. package/src/core/config.js +227 -0
  27. package/src/core/history-db.js +439 -0
  28. package/src/core/job.js +329 -0
  29. package/src/core/logger.js +382 -0
  30. package/src/core/storage.js +315 -0
  31. package/src/daemon/executor.js +409 -0
  32. package/src/daemon/index.js +873 -0
  33. package/src/daemon/scheduler.js +465 -0
  34. package/src/ipc/client.js +112 -0
  35. package/src/ipc/protocol.js +183 -0
  36. package/src/ipc/server.js +92 -0
  37. package/src/utils/cron.js +205 -0
  38. package/src/utils/datetime.js +237 -0
  39. package/src/utils/duration.js +226 -0
  40. 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
+ };