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,465 @@
1
+ /**
2
+ * Job scheduler for JM2 daemon
3
+ * Manages cron jobs and one-time job scheduling
4
+ */
5
+
6
+ import { getNextRunTime, isValidCronExpression } from '../utils/cron.js';
7
+ import { JobStatus, JobType } from '../core/job.js';
8
+ import { getJobs, saveJobs } from '../core/storage.js';
9
+
10
+ /**
11
+ * Scheduler class - manages job scheduling state and execution timing
12
+ */
13
+ export class Scheduler {
14
+ constructor(options = {}) {
15
+ this.logger = options.logger || console;
16
+ this.executor = options.executor || null;
17
+ this.jobs = new Map(); // Map of job ID to scheduled job info
18
+ this.running = false;
19
+ this.checkInterval = null;
20
+ this.checkIntervalMs = options.checkIntervalMs || 1000; // Check every second
21
+ this.runningJobs = new Set(); // Track currently running job IDs
22
+ this.maxConcurrent = options.maxConcurrent || 10; // Max concurrent job executions
23
+ }
24
+
25
+ /**
26
+ * Start the scheduler
27
+ */
28
+ start() {
29
+ if (this.running) {
30
+ return;
31
+ }
32
+
33
+ this.running = true;
34
+ this.logger.info('Scheduler starting...');
35
+
36
+ // Load jobs from storage
37
+ this.loadJobs();
38
+
39
+ // Start the check interval
40
+ this.checkInterval = setInterval(() => {
41
+ this.tick();
42
+ }, this.checkIntervalMs);
43
+
44
+ this.logger.info('Scheduler started');
45
+ }
46
+
47
+ /**
48
+ * Stop the scheduler
49
+ */
50
+ stop() {
51
+ if (!this.running) {
52
+ return;
53
+ }
54
+
55
+ this.running = false;
56
+
57
+ if (this.checkInterval) {
58
+ clearInterval(this.checkInterval);
59
+ this.checkInterval = null;
60
+ }
61
+
62
+ this.logger.info('Scheduler stopped');
63
+ }
64
+
65
+ /**
66
+ * Load jobs from storage into memory
67
+ */
68
+ loadJobs() {
69
+ const storedJobs = getJobs();
70
+ this.jobs.clear();
71
+
72
+ for (const job of storedJobs) {
73
+ // Assign ID if missing (for backward compatibility with old imports)
74
+ if (!job.id) {
75
+ job.id = this.generateJobId();
76
+ }
77
+ this.addJobToMemory(job);
78
+ }
79
+
80
+ // Handle expired one-time jobs that were missed while daemon was stopped
81
+ this.handleExpiredOneTimeJobs();
82
+
83
+ this.logger.debug(`Loaded ${this.jobs.size} jobs`);
84
+ }
85
+
86
+ /**
87
+ * Handle expired one-time jobs on daemon restart
88
+ * Jobs that are past their run time and still active should be marked as failed
89
+ */
90
+ handleExpiredOneTimeJobs() {
91
+ const now = new Date();
92
+ let expiredCount = 0;
93
+
94
+ for (const [id, job] of this.jobs) {
95
+ if (
96
+ job.type === JobType.ONCE &&
97
+ job.status === JobStatus.ACTIVE &&
98
+ job.runAt
99
+ ) {
100
+ const runAt = new Date(job.runAt);
101
+ if (runAt < now) {
102
+ // Job was scheduled to run while daemon was stopped
103
+ this.updateJobStatus(id, JobStatus.FAILED);
104
+ this.updateJob(id, {
105
+ lastResult: 'failed',
106
+ error: 'Job expired - daemon was not running at scheduled time',
107
+ expiredAt: now.toISOString(),
108
+ });
109
+ expiredCount++;
110
+ this.logger.warn(
111
+ `Job ${id} (${job.name || 'unnamed'}) expired - was scheduled for ${runAt.toISOString()}`
112
+ );
113
+ }
114
+ }
115
+ }
116
+
117
+ if (expiredCount > 0) {
118
+ this.logger.info(`Marked ${expiredCount} expired one-time job(s) as failed`);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Add a job to the in-memory map
124
+ * @param {object} job - Job object
125
+ */
126
+ addJobToMemory(job) {
127
+ // Ensure job has an ID
128
+ if (!job.id) {
129
+ job.id = this.generateJobId();
130
+ }
131
+
132
+ // Calculate next run time for active jobs
133
+ let nextRun = null;
134
+ if (job.status === JobStatus.ACTIVE) {
135
+ nextRun = this.calculateNextRun(job);
136
+ }
137
+
138
+ this.jobs.set(job.id, {
139
+ ...job,
140
+ nextRun,
141
+ });
142
+
143
+ // Update job in storage with next run time
144
+ this.updateJobNextRun(job.id, nextRun);
145
+ }
146
+
147
+ /**
148
+ * Calculate the next run time for a job
149
+ * @param {object} job - Job object
150
+ * @returns {Date|null} Next run time or null
151
+ */
152
+ calculateNextRun(job) {
153
+ if (job.status !== JobStatus.ACTIVE) {
154
+ return null;
155
+ }
156
+
157
+ if (job.type === JobType.CRON && job.cron) {
158
+ return getNextRunTime(job.cron);
159
+ }
160
+
161
+ if (job.type === JobType.ONCE && job.runAt) {
162
+ // Always return the runAt date for one-time jobs
163
+ // This allows jobs that are already due to be processed
164
+ return new Date(job.runAt);
165
+ }
166
+
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Get jobs that are due to run
172
+ * @returns {Array} Array of jobs that should run now
173
+ */
174
+ getDueJobs() {
175
+ const now = new Date();
176
+ const dueJobs = [];
177
+
178
+ for (const [id, job] of this.jobs) {
179
+ if (job.status !== JobStatus.ACTIVE) {
180
+ continue;
181
+ }
182
+
183
+ if (job.nextRun && job.nextRun <= now) {
184
+ dueJobs.push({ ...job });
185
+ }
186
+ }
187
+
188
+ return dueJobs;
189
+ }
190
+
191
+ /**
192
+ * Execute a job using the executor
193
+ * @param {object} job - Job to execute
194
+ */
195
+ async executeJob(job) {
196
+ if (!this.executor) {
197
+ this.logger.warn(`Cannot execute job ${job.id}: no executor configured`);
198
+ return;
199
+ }
200
+
201
+ if (this.runningJobs.has(job.id)) {
202
+ this.logger.warn(`Job ${job.id} is already running`);
203
+ return;
204
+ }
205
+
206
+ // Check concurrent execution limit
207
+ if (this.runningJobs.size >= this.maxConcurrent) {
208
+ this.logger.warn(`Cannot execute job ${job.id}: max concurrent jobs (${this.maxConcurrent}) reached`);
209
+ return;
210
+ }
211
+
212
+ this.runningJobs.add(job.id);
213
+ this.logger.info(`Executing job ${job.id} (${job.name || 'unnamed'})`);
214
+
215
+ try {
216
+ const result = await this.executor.executeJobWithRetry(job);
217
+
218
+ // Update job stats
219
+ const updatedJob = this.jobs.get(job.id);
220
+ if (updatedJob) {
221
+ updatedJob.runCount = (updatedJob.runCount || 0) + 1;
222
+ updatedJob.lastRun = new Date().toISOString();
223
+ updatedJob.lastResult = result.status === 'success' ? 'success' : 'failed';
224
+ this.persistJobs();
225
+ }
226
+
227
+ this.logger.info(`Job ${job.id} completed: ${result.status === 'success' ? 'success' : 'failed'}`);
228
+ } catch (error) {
229
+ this.logger.error(`Job ${job.id} failed: ${error.message}`);
230
+
231
+ const updatedJob = this.jobs.get(job.id);
232
+ if (updatedJob) {
233
+ updatedJob.runCount = (updatedJob.runCount || 0) + 1;
234
+ updatedJob.lastRun = new Date().toISOString();
235
+ updatedJob.lastResult = 'failed';
236
+ this.persistJobs();
237
+ }
238
+ } finally {
239
+ this.runningJobs.delete(job.id);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Process due jobs - called on each tick
245
+ * @returns {Array} Jobs that were processed
246
+ */
247
+ tick() {
248
+ if (!this.running) {
249
+ return [];
250
+ }
251
+
252
+ const dueJobs = this.getDueJobs();
253
+
254
+ for (const job of dueJobs) {
255
+ this.logger.debug(`Job ${job.id} (${job.name || 'unnamed'}) is due`);
256
+
257
+ // Execute the job
258
+ this.executeJob(job);
259
+
260
+ // For one-time jobs, mark as completed after scheduling
261
+ if (job.type === JobType.ONCE) {
262
+ this.updateJobStatus(job.id, JobStatus.COMPLETED);
263
+ } else {
264
+ // For cron jobs, recalculate next run time
265
+ const nextRun = this.calculateNextRun({ ...job, status: JobStatus.ACTIVE });
266
+ this.updateJobNextRun(job.id, nextRun);
267
+ }
268
+ }
269
+
270
+ return dueJobs;
271
+ }
272
+
273
+ /**
274
+ * Add a new job
275
+ * @param {object} jobData - Job data
276
+ * @returns {object} Added job
277
+ */
278
+ addJob(jobData) {
279
+ // Generate ID if not provided
280
+ if (!jobData.id) {
281
+ jobData.id = this.generateJobId();
282
+ }
283
+
284
+ // Determine job type
285
+ if (!jobData.type) {
286
+ if (jobData.cron) {
287
+ jobData.type = JobType.CRON;
288
+ } else if (jobData.runAt) {
289
+ jobData.type = JobType.ONCE;
290
+ }
291
+ }
292
+
293
+ // Calculate initial next run
294
+ const nextRun = this.calculateNextRun(jobData);
295
+ const job = { ...jobData, nextRun };
296
+
297
+ // Add to memory
298
+ this.jobs.set(job.id, job);
299
+
300
+ // Save to storage
301
+ this.persistJobs();
302
+
303
+ this.logger.info(`Job ${job.id} (${job.name || 'unnamed'}) added`);
304
+
305
+ return job;
306
+ }
307
+
308
+ /**
309
+ * Remove a job
310
+ * @param {number} jobId - Job ID
311
+ * @returns {boolean} True if removed
312
+ */
313
+ removeJob(jobId) {
314
+ const job = this.jobs.get(jobId);
315
+ if (!job) {
316
+ return false;
317
+ }
318
+
319
+ this.jobs.delete(jobId);
320
+ this.persistJobs();
321
+
322
+ this.logger.info(`Job ${jobId} removed`);
323
+ return true;
324
+ }
325
+
326
+ /**
327
+ * Update a job
328
+ * @param {number} jobId - Job ID
329
+ * @param {object} updates - Updates to apply
330
+ * @returns {object|null} Updated job or null
331
+ */
332
+ updateJob(jobId, updates) {
333
+ const job = this.jobs.get(jobId);
334
+ if (!job) {
335
+ return null;
336
+ }
337
+
338
+ const updatedJob = {
339
+ ...job,
340
+ ...updates,
341
+ id: jobId, // Don't allow changing ID
342
+ updatedAt: new Date().toISOString(),
343
+ };
344
+
345
+ // Recalculate next run if needed
346
+ updatedJob.nextRun = this.calculateNextRun(updatedJob);
347
+
348
+ this.jobs.set(jobId, updatedJob);
349
+ this.persistJobs();
350
+
351
+ this.logger.info(`Job ${jobId} updated`);
352
+ return updatedJob;
353
+ }
354
+
355
+ /**
356
+ * Update job status
357
+ * @param {number} jobId - Job ID
358
+ * @param {string} status - New status
359
+ * @returns {object|null} Updated job or null
360
+ */
361
+ updateJobStatus(jobId, status) {
362
+ const updates = { status };
363
+
364
+ if (status === JobStatus.ACTIVE) {
365
+ // Recalculate next run when activating
366
+ const job = this.jobs.get(jobId);
367
+ if (job) {
368
+ updates.nextRun = this.calculateNextRun({ ...job, status });
369
+ }
370
+ } else {
371
+ updates.nextRun = null;
372
+ }
373
+
374
+ return this.updateJob(jobId, updates);
375
+ }
376
+
377
+ /**
378
+ * Update job's next run time in storage
379
+ * @param {number} jobId - Job ID
380
+ * @param {Date|null} nextRun - Next run time
381
+ */
382
+ updateJobNextRun(jobId, nextRun) {
383
+ const job = this.jobs.get(jobId);
384
+ if (job) {
385
+ job.nextRun = nextRun;
386
+ job.nextRunISO = nextRun ? nextRun.toISOString() : null;
387
+ }
388
+ this.persistJobs();
389
+ }
390
+
391
+ /**
392
+ * Get all jobs
393
+ * @returns {Array} Array of all jobs
394
+ */
395
+ getAllJobs() {
396
+ return Array.from(this.jobs.values());
397
+ }
398
+
399
+ /**
400
+ * Get a job by ID
401
+ * @param {number} jobId - Job ID
402
+ * @returns {object|null} Job or null
403
+ */
404
+ getJob(jobId) {
405
+ return this.jobs.get(jobId) || null;
406
+ }
407
+
408
+ /**
409
+ * Generate a unique job ID
410
+ * @returns {number} New job ID
411
+ */
412
+ generateJobId() {
413
+ let maxId = 0;
414
+ for (const id of this.jobs.keys()) {
415
+ if (id > maxId) {
416
+ maxId = id;
417
+ }
418
+ }
419
+ return maxId + 1;
420
+ }
421
+
422
+ /**
423
+ * Persist jobs to storage
424
+ */
425
+ persistJobs() {
426
+ const jobsArray = Array.from(this.jobs.values()).map(job => ({
427
+ ...job,
428
+ nextRun: job.nextRun ? job.nextRun.toISOString() : null,
429
+ }));
430
+ saveJobs(jobsArray);
431
+ }
432
+
433
+ /**
434
+ * Get scheduler statistics
435
+ * @returns {object} Statistics object
436
+ */
437
+ getStats() {
438
+ const jobs = this.getAllJobs();
439
+ const activeJobs = jobs.filter(j => j.status === JobStatus.ACTIVE);
440
+ const cronJobs = jobs.filter(j => j.type === JobType.CRON);
441
+ const onceJobs = jobs.filter(j => j.type === JobType.ONCE);
442
+
443
+ return {
444
+ totalJobs: jobs.length,
445
+ activeJobs: activeJobs.length,
446
+ pausedJobs: jobs.filter(j => j.status === JobStatus.PAUSED).length,
447
+ completedJobs: jobs.filter(j => j.status === JobStatus.COMPLETED).length,
448
+ failedJobs: jobs.filter(j => j.status === JobStatus.FAILED).length,
449
+ cronJobs: cronJobs.length,
450
+ onceJobs: onceJobs.length,
451
+ dueJobs: this.getDueJobs().length,
452
+ };
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Create a new scheduler instance
458
+ * @param {object} options - Scheduler options
459
+ * @returns {Scheduler} Scheduler instance
460
+ */
461
+ export function createScheduler(options = {}) {
462
+ return new Scheduler(options);
463
+ }
464
+
465
+ export default Scheduler;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * IPC client for JM2 CLI
3
+ */
4
+
5
+ import { createConnection } from 'node:net';
6
+ import { getSocketPath } from '../utils/paths.js';
7
+
8
+ /**
9
+ * Custom error class for daemon-related errors
10
+ */
11
+ export class DaemonError extends Error {
12
+ constructor(message, code) {
13
+ super(message);
14
+ this.name = 'DaemonError';
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Check if error is a connection refused error (daemon not running)
21
+ * @param {Error} error - Error object
22
+ * @returns {boolean} True if connection refused
23
+ */
24
+ function isConnectionRefusedError(error) {
25
+ return error.code === 'ECONNREFUSED' ||
26
+ error.code === 'ENOENT' ||
27
+ error.message?.includes('connect') ||
28
+ error.message?.includes('No such file or directory');
29
+ }
30
+
31
+ /**
32
+ * Send a message to the daemon
33
+ * @param {object} message - Message to send
34
+ * @param {object} options - Client options
35
+ * @param {number} options.timeoutMs - Timeout in milliseconds
36
+ * @returns {Promise<object>} Response message
37
+ */
38
+ export function send(message, options = {}) {
39
+ const { timeoutMs = 2000 } = options;
40
+ const socketPath = getSocketPath();
41
+
42
+ return new Promise((resolve, reject) => {
43
+ const client = createConnection(socketPath);
44
+ let buffer = '';
45
+ let finished = false;
46
+
47
+ const timeout = setTimeout(() => {
48
+ if (!finished) {
49
+ finished = true;
50
+ client.destroy();
51
+ reject(new DaemonError('IPC request timed out', 'ETIMEOUT'));
52
+ }
53
+ }, timeoutMs);
54
+
55
+ client.on('error', err => {
56
+ if (!finished) {
57
+ finished = true;
58
+ clearTimeout(timeout);
59
+
60
+ // Provide user-friendly error for daemon not running
61
+ if (isConnectionRefusedError(err)) {
62
+ reject(new DaemonError(
63
+ 'Daemon is not running. Start it with: jm2 start',
64
+ 'EDAEMON_NOT_RUNNING'
65
+ ));
66
+ } else {
67
+ reject(new DaemonError(
68
+ `IPC communication failed: ${err.message}`,
69
+ err.code || 'EIPC_ERROR'
70
+ ));
71
+ }
72
+ }
73
+ });
74
+
75
+ client.on('data', data => {
76
+ buffer += data.toString();
77
+ let index;
78
+ while ((index = buffer.indexOf('\n')) !== -1) {
79
+ const line = buffer.slice(0, index).trim();
80
+ buffer = buffer.slice(index + 1);
81
+ if (!line) {
82
+ continue;
83
+ }
84
+ try {
85
+ const response = JSON.parse(line);
86
+ if (!finished) {
87
+ finished = true;
88
+ clearTimeout(timeout);
89
+ client.end();
90
+ resolve(response);
91
+ }
92
+ } catch (error) {
93
+ if (!finished) {
94
+ finished = true;
95
+ clearTimeout(timeout);
96
+ client.end();
97
+ reject(new DaemonError('Invalid JSON response from daemon', 'EINVALID_JSON'));
98
+ }
99
+ }
100
+ }
101
+ });
102
+
103
+ client.on('connect', () => {
104
+ client.write(`${JSON.stringify(message)}\n`);
105
+ });
106
+ });
107
+ }
108
+
109
+ export default {
110
+ send,
111
+ DaemonError,
112
+ };